require 'open3' require 'plist' require_relative 'command_executor' require_relative 'helper' module FastlaneCore class DeviceManager class << self def all(requested_os_type = "") return connected_devices(requested_os_type) + simulators(requested_os_type) end def simulators(requested_os_type = "") UI.verbose("Fetching available simulator devices") @devices = [] os_type = 'unknown' os_version = 'unknown' output = '' Open3.popen3('xcrun simctl list devices') do |stdin, stdout, stderr, wait_thr| output = stdout.read end runtime_info = '' Open3.popen3('xcrun simctl list runtimes') do |stdin, stdout, stderr, wait_thr| # This regex outputs the version info in the format " " runtime_info = stdout.read.lines.map { |v| v.sub(/(\w+ \S+)\s*\((\S+)\s[\S\s]*/, "\\1 \\2") }.drop(1) end exact_versions = Hash.new({}) runtime_info.each do |r| platform, general, exact = r.split exact_versions[platform] = {} unless exact_versions.include?(platform) exact_versions[platform][general] = exact end unless output.include?("== Devices ==") UI.error("xcrun simctl CLI broken, run `xcrun simctl list devices` and make sure it works") UI.user_error!("xcrun simctl not working.") end output.split(/\n/).each do |line| next if line =~ /unavailable/ next if line =~ /^== / if line =~ /^-- / (os_type, os_version) = line.gsub(/-- (.*) --/, '\1').split else next if os_type =~ /^Unavailable/ # " iPad (5th generation) (852A5796-63C3-4641-9825-65EBDC5C4259) (Shutdown)" # This line will turn the above string into # ["iPad (5th generation)", "(852A5796-63C3-4641-9825-65EBDC5C4259)", "(Shutdown)"] matches = line.strip.scan(/^(.*?) (\([^)]*?\)) (\([^)]*?\))$/).flatten.reject(&:empty?) state = matches.pop.to_s.delete('(').delete(')') udid = matches.pop.to_s.delete('(').delete(')') name = matches.join(' ') if matches.count && (os_type == requested_os_type || requested_os_type == "") # This is disabled here because the Device is defined later in the file, and that's a problem for the cop @devices << Device.new(name: name, os_type: os_type, os_version: (exact_versions[os_type][os_version] || os_version), udid: udid, state: state, is_simulator: true) end end end return @devices end def connected_devices(requested_os_type) UI.verbose("Fetching available connected devices") device_types = if requested_os_type == "tvOS" ["AppleTV"] elsif requested_os_type == "iOS" ["iPhone", "iPad", "iPod"] else [] end devices = [] # Return early if no supported devices are being searched for if device_types.count == 0 return devices end usb_devices_output = '' Open3.popen3("system_profiler SPUSBDataType -xml") do |stdin, stdout, stderr, wait_thr| usb_devices_output = stdout.read end device_uuids = [] result = Plist.parse_xml(usb_devices_output) discover_devices(result[0], device_types, device_uuids) if result[0] if device_uuids.count > 0 # instruments takes a little while to return so skip it if we have no devices instruments_devices_output = '' Open3.popen3("instruments -s devices") do |stdin, stdout, stderr, wait_thr| instruments_devices_output = stdout.read end instruments_devices_output.split(/\n/).each do |instruments_device| device_uuids.each do |device_uuid| match = instruments_device.match(/(.+) \(([0-9.]+)\) \[(\h{40}|\h{8}-\h{16})\]?/) if match && match[3].delete("-") == device_uuid devices << Device.new(name: match[1], udid: match[3], os_type: requested_os_type, os_version: match[2], state: "Booted", is_simulator: false) UI.verbose("USB Device Found - \"" + match[1] + "\" (" + match[2] + ") UUID:" + match[3]) end end end end return devices end # Recursively handle all USB items, discovering devices that match the # desired types. def discover_devices(usb_item, device_types, discovered_device_udids) (usb_item['_items'] || []).each do |child_item| discover_devices(child_item, device_types, discovered_device_udids) end is_supported_device = device_types.any?(usb_item['_name']) serial_num = usb_item['serial_num'] || '' has_serial_number = serial_num.length == 40 || serial_num.length == 24 if is_supported_device && has_serial_number discovered_device_udids << serial_num end end def latest_simulator_version_for_device(device) simulators.select { |s| s.name == device } .sort_by { |s| Gem::Version.create(s.os_version) } .last .os_version end # The code below works from Xcode 7 on # def all # UI.verbose("Fetching available devices") # @devices = [] # output = '' # Open3.popen3('xcrun simctl list devices --json') do |stdin, stdout, stderr, wait_thr| # output = stdout.read # end # begin # data = JSON.parse(output) # rescue => ex # UI.error(ex) # UI.error("xcrun simctl CLI broken, run `xcrun simctl list devices` and make sure it works") # UI.user_error!("xcrun simctl not working.") # end # data["devices"].each do |os_version, l| # l.each do |device| # next if device['availability'].include?("unavailable") # next unless os_version.include?(requested_os_type) # os = os_version.gsub(requested_os_type + " ", "").strip # @devices << Device.new(name: device['name'], os_type: requested_os_type, os_version: os, udid: device['udid']) # end # end # return @devices # end end # Use the UDID for the given device when setting the destination # Why? Because we might get this error message # > The requested device could not be found because multiple devices matched the request. # # This happens when you have multiple simulators for a given device type / iOS combination # { platform:iOS Simulator, id:1685B071-AFB2-4DC1-BE29-8370BA4A6EBD, OS:9.0, name:iPhone 5 } # { platform:iOS Simulator, id:A141F23B-96B3-491A-8949-813B376C28A7, OS:9.0, name:iPhone 5 } # # We don't want to deal with that, so we just use the UDID class Device attr_accessor :name attr_accessor :udid attr_accessor :os_type attr_accessor :os_version attr_accessor :ios_version # Preserved for backwards compatibility attr_accessor :state attr_accessor :is_simulator def initialize(name: nil, udid: nil, os_type: nil, os_version: nil, state: nil, is_simulator: nil) self.name = name self.udid = udid self.os_type = os_type self.os_version = os_version self.ios_version = os_version self.state = state self.is_simulator = is_simulator end def to_s self.name end def boot return unless is_simulator return unless os_type == "iOS" return if self.state == 'Booted' UI.message("Booting #{self}") `xcrun simctl boot #{self.udid} 2>/dev/null` self.state = 'Booted' end def shutdown return unless is_simulator return unless os_type == "iOS" return if self.state != 'Booted' UI.message("Shutting down #{self.udid}") `xcrun simctl shutdown #{self.udid} 2>/dev/null` self.state = 'Shutdown' end def reset UI.message("Resetting #{self}") shutdown `xcrun simctl erase #{self.udid}` end def delete UI.message("Deleting #{self}") shutdown `xcrun simctl delete #{self.udid}` end def disable_slide_to_type return unless is_simulator return unless os_type == "iOS" return unless Gem::Version.new(os_version) >= Gem::Version.new('13.0') UI.message("Disabling 'Slide to Type' #{self}") plist_buddy = '/usr/libexec/PlistBuddy' plist_buddy_cmd = "-c \"Add :KeyboardContinuousPathEnabled bool false\"" plist_path = File.expand_path("~/Library/Developer/CoreSimulator/Devices/#{self.udid}/data/Library/Preferences/com.apple.keyboard.ContinuousPath.plist") Helper.backticks("#{plist_buddy} #{plist_buddy_cmd} #{plist_path} >/dev/null 2>&1") end end end class Simulator class << self def all return DeviceManager.simulators('iOS') end # Reset all simulators of this type def reset_all all.each(&:reset) end def reset_all_by_version(os_version: nil) return false unless os_version all.select { |device| device.os_version == os_version }.each(&:reset) end # Reset simulator by UDID or name and OS version # Latter is useful when combined with -destination option of xcodebuild def reset(udid: nil, name: nil, os_version: nil) match = all.detect { |device| device.udid == udid || device.name == name && device.os_version == os_version } match.reset if match end # Delete all simulators of this type def delete_all all.each(&:delete) end def delete_all_by_version(os_version: nil) return false unless os_version all.select { |device| device.os_version == os_version }.each(&:delete) end # Disable 'Slide to Type' by UDID or name and OS version # Latter is useful when combined with -destination option of xcodebuild def disable_slide_to_type(udid: nil, name: nil, os_version: nil) match = all.detect { |device| device.udid == udid || device.name == name && device.os_version == os_version } match.disable_slide_to_type if match end def clear_cache @devices = nil end def launch(device) return unless device.is_simulator simulator_path = File.join(Helper.xcode_path, 'Applications', 'Simulator.app') UI.verbose("Launching #{simulator_path} for device: #{device.name} (#{device.udid})") Helper.backticks("open -a #{simulator_path} --args -CurrentDeviceUDID #{device.udid}", print: FastlaneCore::Globals.verbose?) end def copy_logs(device, log_identity, logs_destination_dir, log_collection_start_time) logs_destination_dir = File.expand_path(logs_destination_dir) os_version = FastlaneCore::CommandExecutor.execute(command: 'sw_vers -productVersion', print_all: false, print_command: false) host_computer_supports_logarchives = Gem::Version.new(os_version) >= Gem::Version.new('10.12.0') device_supports_logarchives = Gem::Version.new(device.os_version) >= Gem::Version.new('10.0') are_logarchives_supported = device_supports_logarchives && host_computer_supports_logarchives if are_logarchives_supported copy_logarchive(device, log_identity, logs_destination_dir, log_collection_start_time) else copy_logfile(device, log_identity, logs_destination_dir) end end def uninstall_app(app_identifier, device_type, device_udid) UI.verbose("Uninstalling app '#{app_identifier}' from #{device_type}...") UI.message("Launch Simulator #{device_type}") Helper.backticks("xcrun instruments -w #{device_udid} &> /dev/null") UI.message("Uninstall application #{app_identifier}") Helper.backticks("xcrun simctl uninstall #{device_udid} #{app_identifier} &> /dev/null") end private def copy_logfile(device, log_identity, logs_destination_dir) logfile_src = File.expand_path("~/Library/Logs/CoreSimulator/#{device.udid}/system.log") return unless File.exist?(logfile_src) FileUtils.mkdir_p(logs_destination_dir) logfile_dst = File.join(logs_destination_dir, "system-#{log_identity}.log") FileUtils.rm_f(logfile_dst) FileUtils.cp(logfile_src, logfile_dst) UI.success("Copying file '#{logfile_src}' to '#{logfile_dst}'...") end def copy_logarchive(device, log_identity, logs_destination_dir, log_collection_start_time) require 'shellwords' logarchive_dst = File.join(logs_destination_dir, "system_logs-#{log_identity}.logarchive") FileUtils.rm_rf(logarchive_dst) FileUtils.mkdir_p(File.expand_path("..", logarchive_dst)) logs_collection_start = log_collection_start_time.strftime('%Y-%m-%d %H:%M:%S') command = "xcrun simctl spawn #{device.udid} log collect " command << "--start '#{logs_collection_start}' " command << "--output #{logarchive_dst.shellescape} 2>/dev/null" FastlaneCore::CommandExecutor.execute(command: command, print_all: false, print_command: true) end end end class SimulatorTV < Simulator class << self def all return DeviceManager.simulators('tvOS') end end end class SimulatorWatch < Simulator class << self def all return DeviceManager.simulators('watchOS') end end end end