#!/usr/bin/env ruby # ---------------------------------------------------------------------------------------------------------------------------------------------------------- # AppLovin Quality Service Setup Script for iOS # (c) 2020 AppLovin. All Rights Reserved # # Syntax: # ruby AppLovinQualityServiceSetup-xyz.rb # # Commands: # install - Installs AppLovinQualityService onto an iOS target # uninstall - Uninstalls AppLovinQualityService from a target # update - Updates the AppLovinQualityService pod to the latest version, or to specific version if version number follows. # This command should only be used from within the context of an Xcode build phase # # Options: # -targetid - installs on a specific target identified by its ID as appears in the xcodeproj file (applicable to "install" and "uninstall") # -targetname - installs on a specific target identified by its name (applicable to "install" and "uninstall") # -localframeworks - instructs AppLovinQualityService Plugin to analyze these frameworks looking for SDKs # -version - installs a specific version and blocks automatic future updates (applicable to "install") # # Examples: # ruby AppLovinQualityServiceSetup-xyz.rb (when no parameters given, invokes the "install" command) # ruby AppLovinQualityServiceSetup-xyz.rb install -targetname MyTarget (installs on target name "MyTarget") # ruby AppLovinQualityServiceSetup-xyz.rb uninstall # ruby AppLovinQualityServiceSetup-xyz.rb uninstall -targetname MyTarget # ---------------------------------------------------------------------------------------------------------------------------------------------------------- require 'net/http' require 'openssl' require 'rexml/document' require 'fileutils' require 'date' require 'digest' require 'json' require 'base64' require 'tmpdir' require 'securerandom' # Developer-specific ID application_data=< msg raise "Failed to find api_key in #{__FILE__} file, reason: #{msg}" end def fetch_setup_script(force=false) read_last_update return if !force && ((Time.now - @last_update) < CHECK_INTERVAL) && @specific_version.nil? api_key = get_own_api_key uri = URI(SCRIPT_FETCH_URI) puts "Downloading latest setup script from #{uri.host}..." postdata = {:api_key => api_key, :maven_repo => MAVEN_REPO} postdata[:maven_user] = MAVEN_USER unless MAVEN_USER.nil? postdata[:maven_password] = MAVEN_PASSWORD unless MAVEN_PASSWORD.nil? postdata[:setup_script_version] = @version unless @version.nil? res = Net::HTTP.post(uri, postdata.to_json, "Content-Type" => "application/json") status_code = res.code.to_i if status_code == 304 puts "You already have the latest setup script." return end raise "Failed to fetch latest setup script, status code: #{status_code}" if status_code >= 300 md5_index = res.body.index(CHECKSUM_PREFIX) raise "Missing file checksum" if md5_index.nil? raise "Illegal setup file structure" if res.body.length < 7 file_checksum = res.body[(md5_index+CHECKSUM_PREFIX.length)..(res.body.length-1)] original_setup_file = res.body[0..md5_index - 1] content_md5 = Digest::MD5.hexdigest(original_setup_file) raise "Setup file checksum failed" if content_md5 != file_checksum Dir.mktmpdir do |tmpdir| tmp_script_path = "#{tmpdir}/#{SCRIPT_NAME}" File.open(tmp_script_path, 'w') { |file| file.write(res.body) } FileUtils.mv(tmp_script_path, SCRIPT_PATH) if File.exist?(tmp_script_path) puts "Setup script has been updated." end rescue Exception => e puts "Error downloading setup script: #{e.message}.\nSetup script will not be updated in this run." end def read_last_update @last_update = Time.at(0) content = File.read(LAST_UPDATE_PATH) return unless content @last_update = DateTime.iso8601(content.strip).to_time rescue Exception # ignore end def update_last_update File.open(LAST_UPDATE_PATH, 'w') { |file| file.write(DateTime.now.to_s) } rescue nil end def read_pod_version path = "#{APPLOVIN_QUALITY_SERVICE_PATH}/#{VERSION_FILE}" content = File.read(path) Gem::Version.new(content) rescue Exception LOWEST_VERSION end def clean_repo_periodically(repo_path) return unless File.exist?(repo_path) dirs = Dir.entries(repo_path).select{|f| f != '.' && f != '..' && File.directory?(File.join(repo_path, f)) } dirs.each do |dir| path = "#{repo_path}/#{dir}" last_used_path = "#{path}/#{LAST_USED_FILE}" content = File.read(last_used_path) rescue nil next unless content last_used = DateTime.iso8601(content.strip).to_time rescue Time.at(0) FileUtils.rm_rf(path) if (Time.now - last_used) > CLEANUP_INTERVAL end end def init_repo(repo_path) repo = {path: repo_path} FileUtils.mkdir_p repo_path dirs = Dir.entries(repo_path).select{|f| f != '.' && f != '..' && File.directory?(File.join(repo_path, f)) } repo[:local_versions] = dirs.map{|dir| Gem::Version.new(dir) rescue nil}.compact.sort repo[:highest_local_version] = repo[:local_versions].max || LOWEST_VERSION repo end def init_and_cleanup @repos = POD_REPO_PATHS.map{|repo_path| init_repo(repo_path)} # cleaning old versions from repos POD_REPO_PATHS.map{|repo_path| clean_repo_periodically(repo_path)} POD_REPO_PATHS_LEGACY.map{|repo_path| clean_repo_periodically(repo_path)} MAVEN_ARTIFACT_IDS.each_with_index {|artifact_id, i| @repos[i][:artifact_id] = artifact_id} @repos[0][:minimal_version] = LOWEST_VERSION @repos[1][:minimal_version] = CUTOFF_VERSION read_last_update FileUtils.mkdir_p APPLOVIN_QUALITY_SERVICE_PATH @previous_pod_version = read_pod_version FileUtils.rm_rf BUILD_ROOT_TMP rescue nil end def confirm_xcode_projects_exist project_paths = Dir.glob("#{SCRIPT_DIR}/*.xcodeproj") raise "Could not find Xcode project file(s) under #{SCRIPT_DIR}\nPlease copy this script to your Xcode project directory and run it from there." unless project_paths.count > 0 project_paths end def update_last_used(pod_zip) last_used_path = "#{File.dirname(pod_zip)}/#{LAST_USED_FILE}" File.open(last_used_path, 'w') { |file| file.write(DateTime.now.to_s) } rescue nil end def fetch(uri_str, limit=10) raise 'Too many HTTP redirects' if limit == 0 uri = URI(uri_str) Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https', :verify_mode => OpenSSL::SSL::VERIFY_NONE, :open_timeout => 120) do |http| request = Net::HTTP::Get.new uri.request_uri request.basic_auth MAVEN_USER, MAVEN_PASSWORD unless MAVEN_USER.nil? resp = http.request(request) case resp when Net::HTTPSuccess resp.body when Net::HTTPRedirection location = resp['location'] fetch(location, limit - 1) else raise "Server response code: #{resp.code}" end end rescue Exception => e raise "AppLovin Maven repository returned the following error: #{e.message}" end def get_latest_remote_version(artifact_id) begin uri = URI("https://#{MAVEN_REPO}/#{MAVEN_GROUP}/#{artifact_id}/maven-metadata.xml") maven_data = fetch(uri) rescue Exception => e raise "Failed to access AppLovin Maven repository: #{e.message}" end begin doc = REXML::Document.new maven_data group_id = doc.root.elements["groupId"].text found_artifact_id = doc.root.elements["artifactId"].text version = doc.root.elements["versioning/release"].text rescue Exception raise "Failed to parse maven metadata received from server" end # Some basic validation if group_id != MAVEN_GROUP_ID || found_artifact_id != artifact_id raise "Failed to properly parse Maven metadata" end Gem::Version.new(version) rescue LOWEST_VERSION rescue Exception => e puts e.message nil end def download_pod(repo, version) repo_path = repo[:path] artifact_id = repo[:artifact_id] version_str = version.to_s.description pod_zip = "#{repo_path}/#{version_str}/#{artifact_id}-#{version_str}.zip" puts "Downloading #{SERVICE_NAME} Pod version #{version_str}..." uri = URI("https://#{MAVEN_REPO}/#{MAVEN_GROUP}/#{artifact_id}/#{version_str}/#{artifact_id}-#{version_str}.#{MAVEN_ARTIFACT_TYPE}") content = fetch(uri) puts "Validating #{SERVICE_NAME} Pod checksum..." uri = URI("https://#{MAVEN_REPO}/#{MAVEN_GROUP}/#{artifact_id}/#{version_str}/#{artifact_id}-#{version_str}.#{MAVEN_ARTIFACT_TYPE}.md5") md5 = fetch(uri) raise "Failed to validate Pod MD5 checksum" unless Digest::MD5.hexdigest(content) == md5 FileUtils.mkdir_p "#{repo_path}/#{version_str}" open(pod_zip, "wb") do |file| file.write(content) end pod_zip end def eligible_for_xcframework xcode_version_str = ENV["XCODE_VERSION_ACTUAL"] return true if xcode_version_str.nil? xcode_version = xcode_version_str[0..1].to_i if ENV["COMMAND_MODE"] == "legacy" puts "NOTE: your project cannot be updated to the latest version of AppLovin Quality Service as it is currently using the Legacy build system." return false end if (xcode_version < 11) || (xcode_version < 12 && ENV["ENABLE_BITCODE"] == "YES") puts "NOTE: your project cannot be updated to the latest version of AppLovin Quality Service as it is using an old Xcode version. We recommend that you update to the latest Xcode version." return false end true end def get_pod_zip if @specific_version @repos.each do |repo| if repo[:local_versions].include?(@specific_version) version_str = @specific_version.to_s.description pod_zip = "#{repo[:path]}/#{version_str}/#{repo[:artifact_id]}-#{version_str}.zip" if File.exist?(pod_zip) puts "Requested version #{version_str} of #{SERVICE_NAME} Pod found locally" return pod_zip, @specific_version end end end end repos = eligible_for_xcframework ? @repos : [@repos[0]] update_required = false version = @specific_version if version.nil? repos.each do |repo| if repo[:highest_local_version] && @command != CMD_INSTALL if (Time.now - @last_update) < CHECK_INTERVAL version = [version || LOWEST_VERSION, repo[:highest_local_version]].max else update_required = true end end end end puts "#{SERVICE_NAME} Pod was updated within the last #{CHECK_INTERVAL.to_i / 3600} hours, no update required" unless update_required || version.nil? if version.nil? remote_version = repos.map{|repo| get_latest_remote_version(repo[:artifact_id])}.compact.max highest_local_version = repos.map{|repo| repo[:highest_local_version]}.max if remote_version puts "#{SERVICE_NAME} Pod has the latest version, no update required" if highest_local_version == remote_version update_last_update version = remote_version else version = highest_local_version end end raise "#{SERVICE_NAME} Pod version could not be evaluated" if version.nil? || version == LOWEST_VERSION repo = @repos.select{|repo| version >= repo[:minimal_version]}.last version_str = version.to_s.description pod_zip = "#{repo[:path]}/#{version_str}/#{repo[:artifact_id]}-#{version_str}.zip" pod_zip = download_pod(repo, version) unless File.exist?(pod_zip) return pod_zip, version end def dirs_identical?(*dirs) signatures = dirs.map{|dir| Dir["#{dir}/**/*"].select{|p| File.file?(p)}.sort.map{|p| Digest::MD5.hexdigest(File.read(p)) rescue nil}.compact.join} signatures.uniq.size == 1 end def copy_dsym_to_products_dir built_products_dir = ENV["BUILT_PRODUCTS_DIR"] return if built_products_dir.nil? src = "#{APPLOVIN_QUALITY_SERVICE_PATH}/#{CLIENT_FRAMEWORK_DSYM}" return unless File.exist?(src) dest = "#{built_products_dir}/#{CLIENT_FRAMEWORK_DSYM}" return if File.exist?(dest) && dirs_identical?(src, dest) FileUtils.rm_rf(dest) FileUtils.cp_r(src, dest) end def refresh_framework_in_products_dir built_products_dir = ENV["BUILT_PRODUCTS_DIR"] return if built_products_dir.nil? sdk_name = ENV["SDK_NAME"] return if sdk_name.nil? dest = "#{built_products_dir}/#{CLIENT_FRAMEWORK}" return unless File.exist?(dest) return unless File.exist?("#{APPLOVIN_QUALITY_SERVICE_PATH}/#{CLIENT_XCFRAMEWORK}") framework_paths = Dir["#{APPLOVIN_QUALITY_SERVICE_PATH}/#{CLIENT_XCFRAMEWORK}/ios-*"] simulator_framework_path = framework_paths.select{|p|p.include?("x86")}.first device_framework_path = framework_paths.select{|p| !p.include?("x86")}.first return if simulator_framework_path.nil? || device_framework_path.nil? framework_path = sdk_name.start_with?("iphonesimulator") ? simulator_framework_path : device_framework_path src = "#{framework_path}/#{CLIENT_FRAMEWORK}" return if dirs_identical?(src, dest) FileUtils.rm_rf(dest) FileUtils.cp_r(src, built_products_dir) end def inflate_pod(pod_zip) Dir.mktmpdir do |tmpdir| puts "Extracting Pod #{File.basename(pod_zip)}..." dest = File.join(tmpdir,SERVICE_NAME) is_ok = system("unzip -qq -o \"#{pod_zip}\" -d \"#{dest}\"") raise "Failed to unzip the #{SERVICE_NAME} Pod" unless is_ok unless dirs_identical?(APPLOVIN_QUALITY_SERVICE_PATH, dest) FileUtils.rm_rf(APPLOVIN_QUALITY_SERVICE_PATH) FileUtils.mv(dest, APPLOVIN_QUALITY_SERVICE_PATH) end end update_last_used(pod_zip) copy_dsym_to_products_dir refresh_framework_in_products_dir end def inflate_installer(tmpdir) installer_path = "#{tmpdir}/AppLovinQualityServiceInstaller.app" FileUtils.mkdir_p installer_path installer_zip_path = "#{installer_path}.zip" installer_zip_content = Base64.decode64(@installer) File.open(installer_zip_path, 'w') { |file| file.write(installer_zip_content) } is_ok = system("unzip -qq -o \"#{installer_zip_path}\" -d \"#{installer_path}\"") raise "Failed to inflate installer into #{tmpdir}" unless is_ok installer_path end def invoke_installer Dir.mktmpdir do |tmpdir| installer_path = inflate_installer(tmpdir) installer_exec = "#{installer_path}/#{INSTALLER_EXECUTABLE}" additional_params = "" additional_params += " -targetid \"#{@target_id}\"" if @target_id additional_params += " -targetname \"#{@target_name}\"" if @target_name additional_params += " -localframeworks \"#{@local_frameworks}\"" if @local_frameworks invocation = "\"#{installer_exec}\" #{@command} -projectdir \"#{SCRIPT_DIR}\" -setupfile \"#{SCRIPT_NAME}\" -nosig #{additional_params}" invocation += " -version \"#{@specific_version.to_s.description}\"" if @specific_version is_ok = system(invocation) raise "Failed to apply the #{SERVICE_NAME} Pod" unless is_ok end end def invoke_installer_update(command) project_file = ENV["PROJECT_FILE_PATH"] if project_file.nil? raise "Failed to detect project file path - missing environment variable 'PROJECT_FILE_PATH'\nMake sure this setup script with the \"update\" option is executed from within an Xcode build phase." end Dir.mktmpdir do |tmpdir| installer_path = inflate_installer(tmpdir) installer_exec = "#{installer_path}/#{INSTALLER_EXECUTABLE}" invocation = "\"#{installer_exec}\" #{command} -projectfile \"#{project_file}\" -setupfile \"#{SCRIPT_NAME}\" -nosig" system(invocation) end end def move_build_root FileUtils.mv(BUILD_ROOT, BUILD_ROOT_TMP) rescue nil unless BUILD_ROOT.nil? end def downgrade_to_framework_if_needed(actual_version) return false if ENV["NO_INSTALLER"] return false if @previous_pod_version != LOWEST_VERSION && @previous_pod_version < CUTOFF_VERSION return false if actual_version >= CUTOFF_VERSION return false unless File.exist?("#{APPLOVIN_QUALITY_SERVICE_PATH}/#{CLIENT_FRAMEWORK}") did_update = invoke_installer_update(CMD_UPDATE_FRAMEWORK) if did_update move_build_root return true else return false if $?.exitstatus == 10 # This means the installer didn't have to make any change to the project raise "Failed to modify project file to include #{CLIENT_FRAMEWORK}" end end def upgrade_to_xcframework_if_needed(actual_version) return false if ENV["NO_INSTALLER"] return false if @previous_pod_version != LOWEST_VERSION && @previous_pod_version >= CUTOFF_VERSION return false if actual_version < CUTOFF_VERSION return false unless File.exist?("#{APPLOVIN_QUALITY_SERVICE_PATH}/#{CLIENT_XCFRAMEWORK}") did_update = invoke_installer_update(CMD_UPDATE_XCFRAMEWORK) if did_update return true else return false if $?.exitstatus == 10 raise "Failed to modify project file to include #{CLIENT_XCFRAMEWORK}" end end def update_setup pod_version = read_pod_version if @specific_version && (@specific_version < CUTOFF_VERSION) pod_setup_path = "#{APPLOVIN_QUALITY_SERVICE_PATH}/#{SETUP_SCRIPT}" pod_setup_content = File.read(pod_setup_path) rescue nil return if pod_setup_content.nil? current_setup_content = File.read(SCRIPT_PATH) ["MAVEN_REPO","MAVEN_USER","MAVEN_PASSWORD"].each do |symbol| original_line = current_setup_content[/#{symbol}\s*=.*?$/] pod_setup_content.gsub!(/#{symbol}\s*=.*?$/, original_line) end app_data_match = current_setup_content.scan(/(APPLICATION\_DATA.*APPLICATION\_DATA)/m) return if app_data_match.count == 0 app_data = app_data_match.last.first pod_setup_content.gsub!(/APPLICATION\_DATA.*APPLICATION\_DATA/m, app_data) File.write(SCRIPT_PATH, pod_setup_content) puts "Updated setup script to version #{pod_version}" end end def read_arg(i) if i+1 < ARGV.count return ARGV[i+1], i+1 else return nil, i end end def read_specific_version(version_str) begin @specific_version = Gem::Version.new(version_str) rescue raise("Illegal version string: #{version_str}") end raise "Requested version #{@specific_version.to_s.description} does not exist" if @specific_version > Gem::Version.new(@version) end def read_command_line_options(i) while i < ARGV.count case ARGV[i] when "-targetid" @target_id, i = read_arg(i) when "-targetname" @target_name, i = read_arg(i) when "-localframeworks" @local_frameworks, i = read_arg(i) when "-version" specific_version, i = read_arg(i) read_specific_version(specific_version) else raise "Unrecognized command line option #{ARGV[i]}" end i += 1 end end def read_command_line_args @nofetch = ARGV[0] == CMD_NOFETCH i = @nofetch ? 1 : 0 @command = case ARGV[i] when nil CMD_INSTALL when CMD_INSTALL read_command_line_options(i+1) CMD_INSTALL when CMD_UNINSTALL read_command_line_options(i+1) CMD_UNINSTALL when CMD_UPDATE if ARGV[i+1] if ARGV[i+1].start_with?('-') read_command_line_options(i+1) else read_specific_version(ARGV[i+1]) end end CMD_UPDATE else raise "Unrecognized command: #{ARGV[i]}\n" + "Usage: ruby #{File.basename($0)} \n" + "Where:\n" + " command - #{CMD_INSTALL} | #{CMD_UNINSTALL} | #{CMD_UPDATE}\n" end end def print_end_message if @command == CMD_INSTALL puts "If you decide to uninstall the #{SERVICE_NAME} Pod at any stage, you may run:" puts "ruby #{File.basename($0)} uninstall" puts "In case you encounter any issues, please contact us at #{SUPPORT_EMAIL}" elsif @command == CMD_UNINSTALL puts "Please make sure run 'clean' before rebuilding your project." puts "If you uninstalled #{SERVICE_NAME} from all of your targets and projects and no longer need it," puts "you may manually delete the #{APPLOVIN_QUALITY_SERVICE_DIR} directory under your project," puts "as well as delete this #{File.basename($0)} script." puts "In case you encounter any issues, please contact us at #{SUPPORT_EMAIL}" end end def print_framework_update_message(operation, framework_name) puts "--------------------NOTE--------------------" puts "Project file has been #{operation} to include #{framework_name}." puts "This operation is done once and causes the build to stop." puts "All should work fine once you clean your project and re-build it." end # === Start of script ==== did_downgrade = false did_upgrade = false begin STDOUT.sync = true read_command_line_args if @nofetch puts "Executing script version: #{@version}" if @version init_and_cleanup if @command == CMD_UPDATE pod_zip, actual_version = get_pod_zip inflate_pod(pod_zip) did_downgrade = downgrade_to_framework_if_needed(actual_version) did_upgrade = upgrade_to_xcframework_if_needed(actual_version) unless did_downgrade print_framework_update_message("downgraded", CLIENT_FRAMEWORK) if did_downgrade print_framework_update_message("upgraded", CLIENT_XCFRAMEWORK) if did_upgrade else confirm_xcode_projects_exist unless @command == CMD_UNINSTALL pod_zip, actual_version = get_pod_zip inflate_pod(pod_zip) end puts invoke_installer print_end_message end update_setup puts "..DONE" else puts "AppLovin Quality Service Xcode Setup Script" puts "Copyright (c) 2022 AppLovin. All Rights Reserved." puts "Script version: #{@version}" if @version fetch_setup_script args = ARGV.drop(1).map{|arg| '"' + arg + '"'}.join(' ') exec("ruby \"#{SCRIPT_PATH}\" #{CMD_NOFETCH} #{@command} #{args}") end rescue Exception => e abort("\n#{e.message}\n#{SERVICE_NAME} setup FAILED\n\n") end exit(1) if did_downgrade || did_upgrade # Force stop the build #MD5=fb2671816da9da0f94d6bd0c5865a3a9