Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Overhaul how iOS build distribution installation works #49

Merged
merged 20 commits into from
Jan 22, 2025
55 changes: 42 additions & 13 deletions lib/commands/build_distribution/download_and_install.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
require 'cfpropertylist'
require 'zip'
require 'rbconfig'
require 'tmpdir'

module EmergeCLI
module Commands
Expand All @@ -13,7 +14,9 @@ class DownloadAndInstall < EmergeCLI::Commands::GlobalOptions
desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]'
option :build_id, type: :string, required: true, desc: 'Build ID to download'
option :install, type: :boolean, default: true, required: false, desc: 'Install the build on the device'
option :device_id, type: :string, required: false, desc: 'Device id to install the build'
option :device_id, type: :string, desc: 'Specific device ID to target'
option :device_type, type: :string, enum: %w[simulator physical any], default: 'any',
desc: 'Type of device to target (simulator/physical/any)'
option :output, type: :string, required: false, desc: 'Output path for the downloaded build'

def initialize(network: nil)
Expand All @@ -30,6 +33,9 @@ def call(**options)

raise 'Build ID is required' unless @options[:build_id]

output_name = nil
app_id = nil

begin
@network ||= EmergeCLI::Network.new(api_token:)

Expand All @@ -39,24 +45,32 @@ def call(**options)

platform = response['platform']
download_url = response['downloadUrl']
app_id = response['appId']

extension = platform == 'ios' ? 'ipa' : 'apk'
Logger.info 'Downloading build...'
output_name = @options[:output] || "#{@options[:build_id]}.#{extension}"
`curl --progress-bar -L '#{download_url}' -o #{output_name} `
Logger.info "✅ Build downloaded to #{output_name}"

if @options[:install]
install_ios_build(output_name) if platform == 'ios'
install_android_build(output_name) if platform == 'android'
end
rescue StandardError => e
Logger.error "Failed to download build: #{e.message}"
Logger.error 'Check your parameters and try again'
Logger.error "❌ Failed to download build: #{e.message}"
raise e
ensure
@network&.close
end

begin
if @options[:install] && !output_name.nil?
if platform == 'ios'
install_ios_build(output_name, app_id)
elsif platform == 'android'
install_android_build(output_name)
end
end
rescue StandardError => e
Logger.error "❌ Failed to install build: #{e.message}"
raise e
end
end
end

Expand Down Expand Up @@ -86,12 +100,27 @@ def parse_response(response)
end
end

def install_ios_build(build_path)
command = "xcrun devicectl device install app -d #{@options[:device_id]} #{build_path}"
Logger.debug "Running command: #{command}"
`#{command}`

def install_ios_build(build_path, app_id)
device_type = case @options[:device_type]
when 'simulator'
XcodeDeviceManager::DeviceType::SIMULATOR
when 'physical'
XcodeDeviceManager::DeviceType::PHYSICAL
else
XcodeDeviceManager::DeviceType::ANY
end

device_manager = XcodeDeviceManager.new
device = if @options[:device_id]
device_manager.find_device_by_id(@options[:device_id])
else
device_manager.find_device_by_type(device_type, build_path)
end

device.install_app(build_path)
Logger.info '✅ Build installed'
device.launch_app(app_id)
Logger.info '✅ Build launched'
end

def install_android_build(build_path)
Expand Down
3 changes: 3 additions & 0 deletions lib/emerge_cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
require_relative 'utils/project_detector'
require_relative 'utils/macho_parser'
require_relative 'utils/version_check'
require_relative 'utils/xcode_device_manager'
require_relative 'utils/xcode_simulator'
require_relative 'utils/xcode_physical_device'

require 'dry/cli'

Expand Down
157 changes: 157 additions & 0 deletions lib/utils/xcode_device_manager.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
require 'json'
require_relative 'xcode_simulator'
require 'zip'
require 'cfpropertylist'

module EmergeCLI
class XcodeDeviceManager
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went back and forth on the naming here and whether I should just use IosDeviceManager but didn't want to lock us into iOS platform

class DeviceType
SIMULATOR = :simulator
PHYSICAL = :physical
ANY = :any
end

class << self
def get_supported_platforms(ipa_path)
return [] unless ipa_path&.end_with?('.ipa')

Zip::File.open(ipa_path) do |zip_file|
app_entry = zip_file.glob('**/*.app/').first ||
zip_file.glob('**/*.app').first ||
zip_file.find { |entry| entry.name.end_with?('.app/') || entry.name.end_with?('.app') }

raise 'No .app found in .ipa file' unless app_entry

app_dir = app_entry.name.end_with?('/') ? app_entry.name.chomp('/') : app_entry.name
info_plist_path = "#{app_dir}/Info.plist"
info_plist_entry = zip_file.find_entry(info_plist_path)
raise 'Info.plist not found in app bundle' unless info_plist_entry

info_plist_content = info_plist_entry.get_input_stream.read
plist = CFPropertyList::List.new(data: info_plist_content)
info_plist = CFPropertyList.native_types(plist.value)

info_plist['CFBundleSupportedPlatforms'] || []
end
end
end

def find_device_by_id(device_id)
# Quick check based on device ID format
expected_type = device_id.start_with?('00') ? DeviceType::PHYSICAL : DeviceType::SIMULATOR
Logger.debug "Device ID #{device_id} suggests a #{expected_type} device"

if expected_type == DeviceType::PHYSICAL
output = `xcrun devicectl list devices 2>/dev/null`
return XcodePhysicalDevice.new(device_id) if output.include?(device_id)
else
output = `xcrun simctl list devices 2>/dev/null`
return XcodeSimulator.new(device_id) if output.include?(device_id)
end

# If not found, try the other type as fallback
if expected_type == DeviceType::PHYSICAL
output = `xcrun simctl list devices 2>/dev/null`
return XcodeSimulator.new(device_id) if output.include?(device_id)
else
output = `xcrun devicectl list devices 2>/dev/null`
return XcodePhysicalDevice.new(device_id) if output.include?(device_id)
end
trevor-e marked this conversation as resolved.
Show resolved Hide resolved

raise "No device found with ID: #{device_id}"
end

def find_device_by_type(device_type, ipa_path)
case device_type
when DeviceType::SIMULATOR
find_and_boot_most_recently_used_simulator
when DeviceType::PHYSICAL
find_connected_device
when DeviceType::ANY
# Check supported platforms to make intelligent choice
supported_platforms = self.class.get_supported_platforms(ipa_path)
Logger.debug "Build supports platforms: #{supported_platforms.join(', ')}"

if supported_platforms.include?('iPhoneOS')
device = find_connected_device
return device if device

# Only fall back to simulator if it's also supported
unless supported_platforms.include?('iPhoneSimulator')
raise 'Build only supports physical devices, but no device is connected'
end
Logger.info 'No physical device found, falling back to simulator since build supports both'
find_and_boot_most_recently_used_simulator

elsif supported_platforms.include?('iPhoneSimulator')
find_and_boot_most_recently_used_simulator
else
raise "Build doesn't support either physical devices or simulators"
end
end
end

private

def find_connected_device
Logger.info 'Finding connected device...'
devices_json = `xcrun xcdevice list`
Logger.debug "Device list output: #{devices_json}"

devices_data = JSON.parse(devices_json)
physical_devices = devices_data
.select do |device|
device['simulator'] == false &&
device['ignored'] == false &&
device['available'] == true &&
device['platform'] == 'com.apple.platform.iphoneos'
end

Logger.debug "Found physical devices: #{physical_devices}"

if physical_devices.empty?
Logger.info 'No physical connected device found'
return nil
end

device = physical_devices.first
Logger.info "Found connected physical device: #{device['name']} (#{device['identifier']})"
XcodePhysicalDevice.new(device['identifier'])
end

def find_and_boot_most_recently_used_simulator
Logger.info 'Finding and booting most recently used simulator...'
simulators_json = `xcrun simctl list devices --json`
Logger.debug "Simulators JSON: #{simulators_json}"

simulators_data = JSON.parse(simulators_json)

simulators = simulators_data['devices'].flat_map do |runtime, devices|
next [] unless runtime.include?('iOS') # Only include iOS devices

devices.select do |device|
(device['name'].start_with?('iPhone', 'iPad') &&
device['isAvailable'] &&
!device['isDeleted'])
end.map do |device|
version = runtime.match(/iOS-(\d+)-(\d+)/)&.captures&.join('.').to_f
last_booted = device['lastBootedAt'] ? Time.parse(device['lastBootedAt']) : Time.at(0)
[device['udid'], device['state'], version, last_booted]
end
end.sort_by { |_, _, _, last_booted| last_booted }.reverse

Logger.debug "Simulators: #{simulators}"

raise 'No available simulator found' unless simulators.any?

simulator_id, simulator_state, version, last_booted = simulators.first
version_str = version.zero? ? '' : " (#{version})"
last_booted_str = last_booted == Time.at(0) ? 'never' : last_booted.strftime('%Y-%m-%d %H:%M:%S')
Logger.info "Found simulator #{simulator_id}#{version_str} (#{simulator_state}, last booted: #{last_booted_str})"

simulator = XcodeSimulator.new(simulator_id)
simulator.boot unless simulator_state == 'Booted'
simulator
end
end
end
100 changes: 100 additions & 0 deletions lib/utils/xcode_physical_device.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
require 'English'
require 'timeout'
require 'zip'
require 'cfpropertylist'
require 'fileutils'

module EmergeCLI
class XcodePhysicalDevice
def initialize(device_id)
@device_id = device_id
end

def install_app(ipa_path)
raise "Non-IPA file provided: #{ipa_path}" unless ipa_path.end_with?('.ipa')

Logger.info "Installing app to device #{@device_id}..."

begin
# Set a timeout since I've noticed xcrun devicectl can occasionally hang for invalid apps
Timeout.timeout(60) do
command = "xcrun devicectl device install app --device #{@device_id} \"#{ipa_path}\""
Logger.debug "Running command: #{command}"

output = `#{command} 2>&1`
Logger.debug "Install command output: #{output}"

if output.include?('ERROR:') || output.include?('error:')
if output.include?('This provisioning profile cannot be installed on this device')
bundle_id = extract_bundle_id_from_error(output)
raise "Failed to install app: The provisioning profile for #{bundle_id} is not valid for this device. Make sure the device's UDID is included in the provisioning profile."
elsif output.include?('Unable to Install')
error_message = output.match(/Unable to Install.*\n.*NSLocalizedRecoverySuggestion = ([^\n]+)/)&.[](1)
check_device_compatibility(ipa_path)
raise "Failed to install app: #{error_message || 'Unknown error'}"
else
check_device_compatibility(ipa_path)
raise "Failed to install app: #{output}"
end
end

success = $CHILD_STATUS.success?
unless success
check_device_compatibility(ipa_path)
raise "Installation failed with exit code #{$CHILD_STATUS.exitstatus}"
end
end
rescue Timeout::Error
raise 'Installation timed out after 30 seconds. The device might be locked or installation might be stuck. Try unlocking the device and trying again.'
end

true
end

def launch_app(bundle_id)
Logger.info "Launching app #{bundle_id} on device #{@device_id}..."
command = "xcrun devicectl device process launch --device #{@device_id} #{bundle_id}"
Logger.debug "Running command: #{command}"

begin
Timeout.timeout(30) do
output = `#{command} 2>&1`
success = $CHILD_STATUS.success?

unless success
Logger.debug "Launch command output: #{output}"
if output.include?('The operation couldn\'t be completed. Application is restricted')
raise 'Failed to launch app: The app is restricted. Make sure the device is unlocked and the app is allowed to run.'
elsif output.include?('The operation couldn\'t be completed. Unable to launch')
raise 'Failed to launch app: Unable to launch. The app might be in a bad state - try uninstalling and reinstalling.'
else
raise "Failed to launch app #{bundle_id} on device: #{output}"
end
end
end
rescue Timeout::Error
raise 'Launch timed out after 30 seconds. The device might be locked. Try unlocking the device and trying again.'
end

true
end

private

def check_device_compatibility(ipa_path)
supported_platforms = XcodeDeviceManager.get_supported_platforms(ipa_path)
Logger.debug "Supported platforms: #{supported_platforms.join(', ')}"

unless supported_platforms.include?('iPhoneOS')
raise 'This build is not compatible with physical devices. Please use a simulator or make your build compatible with physical devices.'
end

Logger.debug 'Build is compatible with physical devices'
end

def extract_bundle_id_from_error(output)
# Extract bundle ID from error message like "...profile for com.emerge.hn.Hacker-News :"
output.match(/profile for ([\w\.-]+) :/)&.[](1) || 'unknown bundle ID'
end
end
end
Loading
Loading