-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
11 changed files
with
476 additions
and
464 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.