emerge 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ require 'dry/cli'
2
+ require 'json'
3
+ require 'uri'
4
+ require 'async'
5
+ require 'async/barrier'
6
+ require 'async/semaphore'
7
+ require 'async/http/internet/instance'
8
+
9
+ module EmergeCLI
10
+ module Commands
11
+ module Upload
12
+ class Build < EmergeCLI::Commands::GlobalOptions
13
+ desc 'Upload a build to Emerge'
14
+
15
+ option :path, type: :string, required: true, desc: 'Path to the build artifact'
16
+
17
+ # Optional options
18
+ option :api_token, type: :string, required: false,
19
+ desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]'
20
+ option :sha, type: :string, required: false, desc: 'SHA of the commit'
21
+ option :branch, type: :string, required: false, desc: 'Branch name'
22
+ option :repo_name, type: :string, required: false, desc: 'Repository name'
23
+ option :base_sha, type: :string, required: false, desc: 'Base SHA'
24
+ option :previous_sha, type: :string, required: false, desc: 'Previous SHA'
25
+ option :pr_number, type: :string, required: false, desc: 'PR number'
26
+
27
+ def initialize(network: nil, git_info_provider: nil)
28
+ @network = network
29
+ @git_info_provider = git_info_provider
30
+ end
31
+
32
+ def call(**options)
33
+ @options = options
34
+ @profiler = EmergeCLI::Profiler.new(enabled: options[:profile])
35
+ before(options)
36
+
37
+ start_time = Time.now
38
+
39
+ file_path = options[:path]
40
+ file_exists = File.exist?(file_path)
41
+ raise "File not found at path: #{file_path}" unless file_exists
42
+
43
+ file_extension = File.extname(file_path)
44
+ raise "Unsupported file type: #{file_extension}" unless ['.ipa', '.apk', '.aab',
45
+ '.zip'].include?(file_extension)
46
+
47
+ api_token = @options[:api_token] || ENV.fetch('EMERGE_API_TOKEN', nil)
48
+ raise 'API token is required and cannot be blank' if api_token.nil? || api_token.strip.empty?
49
+
50
+ @network ||= EmergeCLI::Network.new(api_token:)
51
+ @git_info_provider ||= GitInfoProvider.new
52
+
53
+ Sync do
54
+ upload_url, upload_id = fetch_upload_url
55
+
56
+ file_size = File.size(file_path)
57
+ Logger.info("Uploading file... (#{file_size} bytes)")
58
+
59
+ File.open(file_path, 'rb') do |file|
60
+ headers = {
61
+ 'Content-Type' => 'application/zip',
62
+ 'Content-Length' => file_size.to_s
63
+ }
64
+
65
+ response = @network.put(
66
+ path: upload_url,
67
+ body: file.read,
68
+ headers: headers
69
+ )
70
+
71
+ unless response.status == 200
72
+ Logger.error("Upload failed with status #{response.status}")
73
+ Logger.error("Response body: #{response.body}")
74
+ raise "Uploading file failed with status #{response.status}"
75
+ end
76
+ end
77
+
78
+ Logger.info('Upload complete successfully!')
79
+ Logger.info "Time taken: #{(Time.now - start_time).round(2)} seconds"
80
+ Logger.info("✅ You can view the build analysis at https://emergetools.com/build/#{upload_id}")
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def fetch_upload_url
87
+ git_result = @git_info_provider.fetch_git_info
88
+ sha = @options[:sha] || git_result.sha
89
+ branch = @options[:branch] || git_result.branch
90
+ base_sha = @options[:base_sha] || git_result.base_sha
91
+ previous_sha = @options[:previous_sha] || git_result.previous_sha
92
+ pr_number = @options[:pr_number] || git_result.pr_number
93
+
94
+ # TODO: Make optional
95
+ raise 'SHA is required' unless sha
96
+ raise 'Branch is required' unless branch
97
+
98
+ payload = {
99
+ sha:,
100
+ branch:,
101
+ repo_name: @options[:repo_name],
102
+ # Optional
103
+ base_sha:,
104
+ previous_sha:,
105
+ pr_number: pr_number&.to_s
106
+ }.compact
107
+
108
+ upload_response = @network.post(
109
+ path: '/upload',
110
+ body: payload,
111
+ headers: { 'Content-Type' => 'application/json' }
112
+ )
113
+ upload_json = parse_response(upload_response)
114
+ upload_id = upload_json.fetch('upload_id')
115
+ upload_url = upload_json.fetch('uploadURL')
116
+ Logger.debug("Got upload ID: #{upload_id}")
117
+
118
+ warning = upload_json['warning']
119
+ Logger.warn(warning) if warning
120
+
121
+ [upload_url, upload_id]
122
+ end
123
+
124
+ def parse_response(response)
125
+ case response.status
126
+ when 200
127
+ JSON.parse(response.read)
128
+ when 400
129
+ error_message = JSON.parse(response.read)['errorMessage']
130
+ raise "Invalid parameters: #{error_message}"
131
+ when 401, 403
132
+ raise 'Invalid API token'
133
+ else
134
+ raise "Creating upload failed with status #{response.status}"
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
data/lib/emerge_cli.rb CHANGED
@@ -13,10 +13,18 @@ require_relative 'commands/reaper/reaper'
13
13
  require_relative 'commands/snapshots/validate_app'
14
14
  require_relative 'commands/order_files/download_order_files'
15
15
  require_relative 'commands/order_files/validate_linkmaps'
16
+ require_relative 'commands/order_files/validate_xcode_project'
17
+ require_relative 'commands/upload/build'
18
+ require_relative 'commands/build_distribution/validate_app'
19
+ require_relative 'commands/build_distribution/download_and_install'
20
+ require_relative 'commands/autofixes/minify_strings'
21
+ require_relative 'commands/autofixes/strip_binary_symbols'
22
+ require_relative 'commands/autofixes/exported_symbols'
16
23
 
17
24
  require_relative 'reaper/ast_parser'
18
25
  require_relative 'reaper/code_deleter'
19
26
 
27
+ require_relative 'utils/environment'
20
28
  require_relative 'utils/git_info_provider'
21
29
  require_relative 'utils/git_result'
22
30
  require_relative 'utils/github'
@@ -27,6 +35,9 @@ require_relative 'utils/profiler'
27
35
  require_relative 'utils/project_detector'
28
36
  require_relative 'utils/macho_parser'
29
37
  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'
30
41
 
31
42
  require 'dry/cli'
32
43
 
@@ -34,6 +45,7 @@ module EmergeCLI
34
45
  extend Dry::CLI::Registry
35
46
 
36
47
  register 'upload', aliases: ['u'] do |prefix|
48
+ prefix.register 'build', Commands::Upload::Build
37
49
  prefix.register 'snapshots', Commands::Upload::Snapshots
38
50
  end
39
51
 
@@ -55,6 +67,18 @@ module EmergeCLI
55
67
  register 'order-files' do |prefix|
56
68
  prefix.register 'download', Commands::DownloadOrderFiles
57
69
  prefix.register 'validate-linkmaps', Commands::ValidateLinkmaps
70
+ prefix.register 'validate-xcode-project', Commands::ValidateXcodeProject
71
+ end
72
+
73
+ register 'build-distribution' do |prefix|
74
+ prefix.register 'validate-app', Commands::BuildDistribution::ValidateApp
75
+ prefix.register 'install', Commands::BuildDistribution::DownloadAndInstall
76
+ end
77
+
78
+ register 'autofix' do |prefix|
79
+ prefix.register 'minify-strings', Commands::Autofixes::MinifyStrings
80
+ prefix.register 'strip-binary-symbols', Commands::Autofixes::StripBinarySymbols
81
+ prefix.register 'exported-symbols', Commands::Autofixes::ExportedSymbols
58
82
  end
59
83
  end
60
84
 
@@ -9,19 +9,22 @@ module EmergeCLI
9
9
  DECLARATION_NODE_TYPES = {
10
10
  'swift' => %i[class_declaration protocol_declaration],
11
11
  'kotlin' => %i[class_declaration protocol_declaration interface_declaration object_declaration],
12
- 'java' => %i[class_declaration protocol_declaration interface_declaration]
12
+ 'java' => %i[class_declaration protocol_declaration interface_declaration],
13
+ 'objc' => %i[class_declaration protocol_declaration class_implementation class_interface]
13
14
  }.freeze
14
15
 
15
16
  IDENTIFIER_NODE_TYPES = {
16
17
  'swift' => %i[simple_identifier qualified_name identifier type_identifier],
17
18
  'kotlin' => %i[simple_identifier qualified_name identifier type_identifier],
18
- 'java' => %i[simple_identifier qualified_name identifier type_identifier]
19
+ 'java' => %i[simple_identifier qualified_name identifier type_identifier],
20
+ 'objc' => %i[simple_identifier qualified_name identifier type_identifier]
19
21
  }.freeze
20
22
 
21
23
  COMMENT_AND_IMPORT_NODE_TYPES = {
22
24
  'swift' => %i[comment import_declaration],
23
25
  'kotlin' => %i[comment import_header],
24
- 'java' => %i[comment import_declaration]
26
+ 'java' => %i[comment import_declaration],
27
+ 'objc' => %i[comment import_declaration preproc_include]
25
28
  }.freeze
26
29
 
27
30
  attr_reader :parser, :language
@@ -52,17 +55,9 @@ module EmergeCLI
52
55
  extension = platform == 'darwin' ? 'dylib' : 'so'
53
56
  parser_file = "libtree-sitter-#{language}-#{platform}-#{arch}.#{extension}"
54
57
  parser_path = File.join('parsers', parser_file)
58
+ raise "No language grammar found for #{language}" unless File.exist?(parser_path)
55
59
 
56
- case language
57
- when 'swift'
58
- @parser.language = TreeSitter::Language.load('swift', parser_path)
59
- when 'kotlin'
60
- @parser.language = TreeSitter::Language.load('kotlin', parser_path)
61
- when 'java'
62
- @parser.language = TreeSitter::Language.load('java', parser_path)
63
- else
64
- raise "Unsupported language: #{language}"
65
- end
60
+ @parser.language = TreeSitter::Language.load(language, parser_path)
66
61
  end
67
62
 
68
63
  # Deletes a type from the given file contents.
@@ -127,6 +122,7 @@ module EmergeCLI
127
122
 
128
123
  while (node = nodes_to_process.shift)
129
124
  identifier_type = identifier_node_types.include?(node.type)
125
+ Logger.debug "Processing node: #{node.type} #{node_text(node)}"
130
126
  declaration_type = if node == tree.root_node
131
127
  false
132
128
  else
@@ -136,6 +132,11 @@ module EmergeCLI
136
132
  usages << { line: node.start_point.row, usage_type: 'declaration' }
137
133
  elsif identifier_type && node_text(node) == type_name
138
134
  usages << { line: node.start_point.row, usage_type: 'identifier' }
135
+ elsif node.type == :@implementation
136
+ next_sibling = node.next_named_sibling
137
+ if next_sibling.type == :identifier && node_text(next_sibling) == type_name
138
+ usages << { line: next_sibling.start_point.row, usage_type: 'declaration' }
139
+ end
139
140
  end
140
141
 
141
142
  node.each { |child| nodes_to_process.push(child) }
@@ -172,14 +173,14 @@ module EmergeCLI
172
173
 
173
174
  return file_contents if nodes_to_remove.empty?
174
175
 
175
- Logger.debug "Found #{nodes_to_remove.length} nodes to remove"
176
+ Logger.debug "Found #{nodes_to_remove.length} nodes to remove"
176
177
  remove_nodes_from_content(file_contents, nodes_to_remove)
177
178
  end
178
179
 
179
180
  private
180
181
 
181
182
  def remove_node(node, lines_to_remove)
182
- Logger.debug "Removing node: #{node.type}"
183
+ Logger.debug "Removing node: #{node.type}"
183
184
  start_position = node.start_point.row
184
185
  end_position = node.end_point.row
185
186
  lines_to_remove << { start: start_position, end: end_position }
@@ -287,7 +288,7 @@ module EmergeCLI
287
288
  when :navigation_expression # NetworkDebugger.printStats
288
289
  result = handle_navigation_expression(current)
289
290
  return result if result
290
- when :class_declaration, :function_declaration, :method_declaration
291
+ when :class_declaration, :function_declaration, :method_declaration, :@implementation
291
292
  Logger.debug "Reached structural element, stopping at: #{current.type}"
292
293
  break
293
294
  end
@@ -163,7 +163,8 @@ module EmergeCLI
163
163
  found_usages = []
164
164
  source_patterns = case @platform&.downcase
165
165
  when 'ios'
166
- { 'swift' => '**/*.swift' }
166
+ { 'swift' => '**/*.swift',
167
+ 'objc' => '**/*.{m,h}' }
167
168
  when 'android'
168
169
  {
169
170
  'kotlin' => '**/*.kt',
@@ -253,6 +254,7 @@ module EmergeCLI
253
254
  when '.swift' then 'swift'
254
255
  when '.kt' then 'kotlin'
255
256
  when '.java' then 'java'
257
+ when '.m', '.h' then 'objc'
256
258
  else
257
259
  raise "Unsupported file type for #{file_path}"
258
260
  end
@@ -0,0 +1,7 @@
1
+ module EmergeCLI
2
+ class Environment
3
+ def execute_command(command)
4
+ `#{command}`
5
+ end
6
+ end
7
+ end
data/lib/utils/git.rb CHANGED
@@ -3,6 +3,7 @@ require 'open3'
3
3
  module EmergeCLI
4
4
  module Git
5
5
  def self.branch
6
+ Logger.debug 'Getting current branch name'
6
7
  command = 'git rev-parse --abbrev-ref HEAD'
7
8
  Logger.debug command
8
9
  stdout, _, status = Open3.capture3(command)
@@ -39,6 +40,7 @@ module EmergeCLI
39
40
  end
40
41
 
41
42
  def self.sha
43
+ Logger.debug 'Getting current SHA'
42
44
  command = 'git rev-parse HEAD'
43
45
  Logger.debug command
44
46
  stdout, _, status = Open3.capture3(command)
@@ -46,6 +48,7 @@ module EmergeCLI
46
48
  end
47
49
 
48
50
  def self.base_sha
51
+ Logger.debug 'Getting base SHA'
49
52
  current_branch = branch
50
53
  remote_head = remote_head_branch
51
54
  return nil if current_branch.nil? || remote_head.nil?
@@ -59,6 +62,7 @@ module EmergeCLI
59
62
  end
60
63
 
61
64
  def self.previous_sha
65
+ Logger.debug 'Getting previous SHA'
62
66
  command = 'git rev-list --count HEAD'
63
67
  Logger.debug command
64
68
  count_stdout, _, count_status = Open3.capture3(command)
@@ -78,12 +82,14 @@ module EmergeCLI
78
82
  end
79
83
 
80
84
  def self.primary_remote
85
+ Logger.debug 'Getting primary remote'
81
86
  remote = remote()
82
87
  return nil if remote.nil?
83
88
  remote.include?('origin') ? 'origin' : remote.first
84
89
  end
85
90
 
86
91
  def self.remote_head_branch(remote = primary_remote)
92
+ Logger.debug 'Getting remote head branch'
87
93
  return nil if remote.nil?
88
94
  command = "git remote show #{remote}"
89
95
  Logger.debug command
@@ -98,6 +104,7 @@ module EmergeCLI
98
104
  end
99
105
 
100
106
  def self.remote_url(remote = primary_remote)
107
+ Logger.debug 'Getting remote URL'
101
108
  return nil if remote.nil?
102
109
  command = "git config --get remote.#{remote}.url"
103
110
  Logger.debug command
@@ -106,6 +113,7 @@ module EmergeCLI
106
113
  end
107
114
 
108
115
  def self.remote
116
+ Logger.debug 'Getting remote'
109
117
  command = 'git remote'
110
118
  Logger.debug command
111
119
  stdout, _, status = Open3.capture3(command)
data/lib/utils/network.rb CHANGED
@@ -17,8 +17,8 @@ module EmergeCLI
17
17
  @internet = Async::HTTP::Internet.new
18
18
  end
19
19
 
20
- def get(path:, headers: {}, max_retries: MAX_RETRIES)
21
- request(:get, path, nil, headers, nil, max_retries)
20
+ def get(path:, headers: {}, query: nil, max_retries: MAX_RETRIES)
21
+ request(:get, path, nil, headers, query, max_retries)
22
22
  end
23
23
 
24
24
  def post(path:, body:, headers: {}, query: nil, max_retries: MAX_RETRIES)
@@ -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
@@ -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