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.
- checksums.yaml +4 -4
- data/lib/commands/autofixes/exported_symbols.rb +62 -0
- data/lib/commands/autofixes/minify_strings.rb +99 -0
- data/lib/commands/autofixes/strip_binary_symbols.rb +116 -0
- data/lib/commands/build_distribution/download_and_install.rb +139 -0
- data/lib/commands/build_distribution/validate_app.rb +164 -0
- data/lib/commands/order_files/validate_xcode_project.rb +71 -0
- data/lib/commands/upload/build.rb +140 -0
- data/lib/emerge_cli.rb +24 -0
- data/lib/reaper/ast_parser.rb +17 -16
- data/lib/reaper/code_deleter.rb +3 -1
- data/lib/utils/environment.rb +7 -0
- data/lib/utils/git.rb +8 -0
- data/lib/utils/network.rb +2 -2
- data/lib/utils/xcode_device_manager.rb +158 -0
- data/lib/utils/xcode_physical_device.rb +108 -0
- data/lib/utils/xcode_simulator.rb +114 -0
- data/lib/version.rb +1 -1
- data/parsers/libtree-sitter-objc-darwin-arm64.dylib +0 -0
- data/parsers/libtree-sitter-objc-linux-x86_64.so +0 -0
- metadata +32 -5
@@ -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
|
|
data/lib/reaper/ast_parser.rb
CHANGED
@@ -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
|
-
|
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
|
data/lib/reaper/code_deleter.rb
CHANGED
@@ -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
|
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,
|
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
|