Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions lib/commands/build/distribution/install.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
require 'dry/cli'
require 'cfpropertylist'
require 'zip'
require 'rbconfig'
require 'tmpdir'

module EmergeCLI
module Commands
module Build
module Distribution
class Install < EmergeCLI::Commands::GlobalOptions
desc 'Download and install a build from Build Distribution'

option :api_token, type: :string, required: false,
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, 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)
@network = network
end

def call(**options)
@options = options
before(options)

Sync do
api_token = @options[:api_token] || ENV.fetch('EMERGE_API_TOKEN', nil)
raise 'API token is required' unless api_token

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

output_name = nil
app_id = nil

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

Logger.info 'Getting build URL...'
request = get_build_url(@options[:build_id])
response = parse_response(request)

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}"
rescue StandardError => e
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

private

def get_build_url(build_id)
@network.get(
path: '/distribution/downloadUrl',
max_retries: 3,
query: {
buildId: build_id
}
)
end

def parse_response(response)
case response.status
when 200
JSON.parse(response.read)
when 400
error_message = JSON.parse(response.read)['errorMessage']
raise "Invalid parameters: #{error_message}"
when 401, 403
raise 'Invalid API token'
else
raise "Getting build failed with status #{response.status}"
end
end

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)
command = "adb -s #{@options[:device_id]} install #{build_path}"
Logger.debug "Running command: #{command}"
`#{command}`

Logger.info '✅ Build installed'
end
end
end
end
end
end
166 changes: 166 additions & 0 deletions lib/commands/build/distribution/validate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
require 'dry/cli'
require 'cfpropertylist'
require 'zip'
require 'rbconfig'

module EmergeCLI
module Commands
module Build
module Distribution
class ValidateApp < EmergeCLI::Commands::GlobalOptions
desc 'Validate app for build distribution'

option :path, type: :string, required: true, desc: 'Path to the xcarchive, IPA or APK to validate'

# Constants
PLIST_START = '<plist'.freeze
PLIST_STOP = '</plist>'.freeze

UTF8_ENCODING = 'UTF-8'.freeze
STRING_FORMAT = 'binary'.freeze
EMPTY_STRING = ''.freeze

EXPECTED_ABI = 'arm64-v8a'.freeze

def call(**options)
@options = options
before(options)

Sync do
file_extension = File.extname(@options[:path])
case file_extension
when '.xcarchive'
handle_xcarchive
when '.ipa'
handle_ipa
when '.app'
handle_app
when '.apk'
handle_apk
else
raise "Unknown file extension: #{file_extension}"
end
end
end

private

def handle_xcarchive
raise 'Path must be an xcarchive' unless @options[:path].end_with?('.xcarchive')

app_path = Dir.glob("#{@options[:path]}/Products/Applications/*.app").first
run_codesign_check(app_path)
read_provisioning_profile(app_path)
end

def handle_ipa
raise 'Path must be an IPA' unless @options[:path].end_with?('.ipa')

Dir.mktmpdir do |tmp_dir|
Zip::File.open(@options[:path]) do |zip_file|
zip_file.each do |entry|
entry.extract(File.join(tmp_dir, entry.name))
end
end

app_path = File.join(tmp_dir, 'Payload/*.app')
app_path = Dir.glob(app_path).first
run_codesign_check(app_path)
read_provisioning_profile(app_path)
end
end

def handle_app
raise 'Path must be an app' unless @options[:path].end_with?('.app')

app_path = @options[:path]
run_codesign_check(app_path)
read_provisioning_profile(app_path)
end

def handle_apk
raise 'Path must be an APK' unless @options[:path].end_with?('.apk')

apk_path = @options[:path]
check_supported_abis(apk_path)
end

def run_codesign_check(app_path)
unless RbConfig::CONFIG['host_os'] =~ /darwin/i
Logger.info 'Skipping codesign check on non-macOS platform'
return
end

command = "codesign -dvvv '#{app_path}'"
Logger.debug command
stdout, _, status = Open3.capture3(command)
Logger.debug stdout
raise '❌ Codesign check failed' unless status.success?

Logger.info '✅ Codesign check passed'
end

def read_provisioning_profile(app_path)
entitlements_path = File.join(app_path, 'embedded.mobileprovision')
raise '❌ Entitlements file not found' unless File.exist?(entitlements_path)

content = File.read(entitlements_path)
lines = content.lines

buffer = ''
inside_plist = false
lines.each do |line|
inside_plist = true if line.include? PLIST_START
if inside_plist
buffer << line
break if line.include? PLIST_STOP
end
end

encoded_plist = buffer.encode(UTF8_ENCODING, STRING_FORMAT, invalid: :replace, undef: :replace,
replace: EMPTY_STRING)
encoded_plist = encoded_plist.sub(/#{PLIST_STOP}.+/, PLIST_STOP)

plist = CFPropertyList::List.new(data: encoded_plist)
parsed_data = CFPropertyList.native_types(plist.value)

expiration_date = parsed_data['ExpirationDate']
if expiration_date > Time.now
Logger.info '✅ Provisioning profile hasn\'t expired'
else
Logger.info "❌ Provisioning profile is expired. Expiration date: #{expiration_date}"
end

provisions_all_devices = parsed_data['ProvisionsAllDevices']
if provisions_all_devices
Logger.info 'Provisioning profile supports all devices (likely an enterprise profile)'
else
devices = parsed_data['ProvisionedDevices']
Logger.info 'Provisioning profile does not support all devices (likely a development profile).'
Logger.info "Devices: #{devices.inspect}"
end
end

def check_supported_abis(apk_path)
abis = []

Zip::File.open(apk_path) do |zip_file|
zip_file.each do |entry|
if entry.name.start_with?('lib/') && entry.name.count('/') == 2
abi = entry.name.split('/')[1]
abis << abi unless abis.include?(abi)
end
end
end

unless abis.include?(EXPECTED_ABI)
raise "APK does not support #{EXPECTED_ABI} architecture, found: #{abis.join(', ')}"
end

Logger.info "✅ APK supports #{EXPECTED_ABI} architecture"
end
end
end
end
end
end
Loading