Skip to content
58 changes: 45 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[virtual physical any], default: 'any',
desc: 'Type of device to target (virtual/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,30 @@ 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::VIRTUAL
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

Logger.info "Installing build on #{device.device_id}"
device.install_app(build_path)
Logger.info '✅ Build installed'

Logger.info "Launching app #{app_id}..."
device.launch_app(app_id)
Logger.info '✅ Build launched'
end

def install_android_build(build_path)
Expand Down
4 changes: 4 additions & 0 deletions lib/emerge_cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
require_relative 'reaper/ast_parser'
require_relative 'reaper/code_deleter'

require_relative 'utils/environment'
require_relative 'utils/git_info_provider'
require_relative 'utils/git_result'
require_relative 'utils/github'
Expand All @@ -34,6 +35,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
7 changes: 7 additions & 0 deletions lib/utils/environment.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module EmergeCLI
class Environment
def execute_command(command)
`#{command}`
end
end
end
158 changes: 158 additions & 0 deletions lib/utils/xcode_device_manager.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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
VIRTUAL = :virtual
PHYSICAL = :physical
ANY = :any
end

def initialize(environment: Environment.new)
@environment = environment
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)
Logger.debug "Looking for device with ID: #{device_id}"
devices_json = execute_command('xcrun xcdevice list')
devices_data = JSON.parse(devices_json)

found_device = devices_data.find { |device| device['identifier'] == device_id }
raise "No device found with ID: #{device_id}" unless found_device

device_type = found_device['simulator'] ? 'simulator' : 'physical'
Logger.info "✅ Found device: #{found_device['name']} " \
"(#{found_device['identifier']}, #{device_type})"
if found_device['simulator']
XcodeSimulator.new(found_device['identifier'])
else
XcodePhysicalDevice.new(found_device['identifier'])
end
end

def find_device_by_type(device_type, ipa_path)
case device_type
when DeviceType::VIRTUAL
find_and_boot_most_recently_used_simulator
when DeviceType::PHYSICAL
find_connected_device
when DeviceType::ANY
# Check supported platforms in Info.plist 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 execute_command(command)
@environment.execute_command(command)
end

def find_connected_device
Logger.info 'Finding connected device...'
devices_json = execute_command('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 = execute_command('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, environment: @environment)
simulator.boot unless simulator_state == 'Booted'
simulator
end
end
end
108 changes: 108 additions & 0 deletions lib/utils/xcode_physical_device.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
require 'English'
require 'timeout'
require 'zip'
require 'cfpropertylist'
require 'fileutils'

module EmergeCLI
class XcodePhysicalDevice
attr_reader :device_id

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)
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