Skip to content

Commit de5babc

Browse files
authored
Overhaul how iOS build distribution installation works (#49)
1 parent 0297193 commit de5babc

File tree

8 files changed

+703
-26
lines changed

8 files changed

+703
-26
lines changed

lib/commands/build_distribution/download_and_install.rb

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
require 'cfpropertylist'
33
require 'zip'
44
require 'rbconfig'
5+
require 'tmpdir'
56

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

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

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

36+
output_name = nil
37+
app_id = nil
38+
3339
begin
3440
@network ||= EmergeCLI::Network.new(api_token:)
3541

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

4046
platform = response['platform']
4147
download_url = response['downloadUrl']
48+
app_id = response['appId']
4249

4350
extension = platform == 'ios' ? 'ipa' : 'apk'
4451
Logger.info 'Downloading build...'
4552
output_name = @options[:output] || "#{@options[:build_id]}.#{extension}"
4653
`curl --progress-bar -L '#{download_url}' -o #{output_name} `
4754
Logger.info "✅ Build downloaded to #{output_name}"
48-
49-
if @options[:install]
50-
install_ios_build(output_name) if platform == 'ios'
51-
install_android_build(output_name) if platform == 'android'
52-
end
5355
rescue StandardError => e
54-
Logger.error "Failed to download build: #{e.message}"
55-
Logger.error 'Check your parameters and try again'
56+
Logger.error "❌ Failed to download build: #{e.message}"
5657
raise e
5758
ensure
5859
@network&.close
5960
end
61+
62+
begin
63+
if @options[:install] && !output_name.nil?
64+
if platform == 'ios'
65+
install_ios_build(output_name, app_id)
66+
elsif platform == 'android'
67+
install_android_build(output_name)
68+
end
69+
end
70+
rescue StandardError => e
71+
Logger.error "❌ Failed to install build: #{e.message}"
72+
raise e
73+
end
6074
end
6175
end
6276

@@ -86,12 +100,30 @@ def parse_response(response)
86100
end
87101
end
88102

89-
def install_ios_build(build_path)
90-
command = "xcrun devicectl device install app -d #{@options[:device_id]} #{build_path}"
91-
Logger.debug "Running command: #{command}"
92-
`#{command}`
93-
103+
def install_ios_build(build_path, app_id)
104+
device_type = case @options[:device_type]
105+
when 'simulator'
106+
XcodeDeviceManager::DeviceType::VIRTUAL
107+
when 'physical'
108+
XcodeDeviceManager::DeviceType::PHYSICAL
109+
else
110+
XcodeDeviceManager::DeviceType::ANY
111+
end
112+
113+
device_manager = XcodeDeviceManager.new
114+
device = if @options[:device_id]
115+
device_manager.find_device_by_id(@options[:device_id])
116+
else
117+
device_manager.find_device_by_type(device_type, build_path)
118+
end
119+
120+
Logger.info "Installing build on #{device.device_id}"
121+
device.install_app(build_path)
94122
Logger.info '✅ Build installed'
123+
124+
Logger.info "Launching app #{app_id}..."
125+
device.launch_app(app_id)
126+
Logger.info '✅ Build launched'
95127
end
96128

97129
def install_android_build(build_path)

lib/emerge_cli.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
require_relative 'reaper/ast_parser'
2525
require_relative 'reaper/code_deleter'
2626

27+
require_relative 'utils/environment'
2728
require_relative 'utils/git_info_provider'
2829
require_relative 'utils/git_result'
2930
require_relative 'utils/github'
@@ -34,6 +35,9 @@
3435
require_relative 'utils/project_detector'
3536
require_relative 'utils/macho_parser'
3637
require_relative 'utils/version_check'
38+
require_relative 'utils/xcode_device_manager'
39+
require_relative 'utils/xcode_simulator'
40+
require_relative 'utils/xcode_physical_device'
3741

3842
require 'dry/cli'
3943

lib/utils/environment.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module EmergeCLI
2+
class Environment
3+
def execute_command(command)
4+
`#{command}`
5+
end
6+
end
7+
end

lib/utils/xcode_device_manager.rb

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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

lib/utils/xcode_physical_device.rb

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
require 'English'
2+
require 'timeout'
3+
require 'zip'
4+
require 'cfpropertylist'
5+
require 'fileutils'
6+
7+
module EmergeCLI
8+
class XcodePhysicalDevice
9+
attr_reader :device_id
10+
11+
def initialize(device_id)
12+
@device_id = device_id
13+
end
14+
15+
def install_app(ipa_path)
16+
raise "Non-IPA file provided: #{ipa_path}" unless ipa_path.end_with?('.ipa')
17+
18+
Logger.info "Installing app to device #{@device_id}..."
19+
20+
begin
21+
# Set a timeout since I've noticed xcrun devicectl can occasionally hang for invalid apps
22+
Timeout.timeout(60) do
23+
command = "xcrun devicectl device install app --device #{@device_id} \"#{ipa_path}\""
24+
Logger.debug "Running command: #{command}"
25+
26+
output = `#{command} 2>&1`
27+
Logger.debug "Install command output: #{output}"
28+
29+
if output.include?('ERROR:') || output.include?('error:')
30+
if output.include?('This provisioning profile cannot be installed on this device')
31+
bundle_id = extract_bundle_id_from_error(output)
32+
raise "Failed to install app: The provisioning profile for #{bundle_id} is not " \
33+
"valid for this device. Make sure the device's UDID is included in the " \
34+
'provisioning profile.'
35+
elsif output.include?('Unable to Install')
36+
error_message = output.match(/Unable to Install.*\n.*NSLocalizedRecoverySuggestion = ([^\n]+)/)&.[](1)
37+
check_device_compatibility(ipa_path)
38+
raise "Failed to install app: #{error_message || 'Unknown error'}"
39+
else
40+
check_device_compatibility(ipa_path)
41+
raise "Failed to install app: #{output}"
42+
end
43+
end
44+
45+
success = $CHILD_STATUS.success?
46+
unless success
47+
check_device_compatibility(ipa_path)
48+
raise "Installation failed with exit code #{$CHILD_STATUS.exitstatus}"
49+
end
50+
end
51+
rescue Timeout::Error
52+
raise 'Installation timed out after 30 seconds. The device might be locked or ' \
53+
'installation might be stuck. Try unlocking the device and trying again.'
54+
end
55+
56+
true
57+
end
58+
59+
def launch_app(bundle_id)
60+
command = "xcrun devicectl device process launch --device #{@device_id} #{bundle_id}"
61+
Logger.debug "Running command: #{command}"
62+
63+
begin
64+
Timeout.timeout(30) do
65+
output = `#{command} 2>&1`
66+
success = $CHILD_STATUS.success?
67+
68+
unless success
69+
Logger.debug "Launch command output: #{output}"
70+
if output.include?('The operation couldn\'t be completed. Application is restricted')
71+
raise 'Failed to launch app: The app is restricted. Make sure the device is ' \
72+
'unlocked and the app is allowed to run.'
73+
elsif output.include?('The operation couldn\'t be completed. Unable to launch')
74+
raise 'Failed to launch app: Unable to launch. The app might be in a bad state - ' \
75+
'try uninstalling and reinstalling.'
76+
else
77+
raise "Failed to launch app #{bundle_id} on device: #{output}"
78+
end
79+
end
80+
end
81+
rescue Timeout::Error
82+
raise 'Launch timed out after 30 seconds. The device might be locked. ' \
83+
'Try unlocking the device and trying again.'
84+
end
85+
86+
true
87+
end
88+
89+
private
90+
91+
def check_device_compatibility(ipa_path)
92+
supported_platforms = XcodeDeviceManager.get_supported_platforms(ipa_path)
93+
Logger.debug "Supported platforms: #{supported_platforms.join(', ')}"
94+
95+
unless supported_platforms.include?('iPhoneOS')
96+
raise 'This build is not compatible with physical devices. Please use a simulator ' \
97+
'or make your build compatible with physical devices.'
98+
end
99+
100+
Logger.debug 'Build is compatible with physical devices'
101+
end
102+
103+
def extract_bundle_id_from_error(output)
104+
# Extract bundle ID from error message like "...profile for com.emerge.hn.Hacker-News :"
105+
output.match(/profile for ([\w\.-]+) :/)&.[](1) || 'unknown bundle ID'
106+
end
107+
end
108+
end

0 commit comments

Comments
 (0)