#!/usr/bin/env ruby # ---------------------------------------------------------------------------------------------------------------------------------------------------------- # AppLovin Quality Service Setup Script for iOS # (c) 2023 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 read_last_update return if ((Time.now - @last_update) < CHECK_INTERVAL) && @specific_version.nil? return if !@specific_version.nil? && @specific_version == Gem::Version.new(@version) api_key = get_own_api_key uri = URI(SCRIPT_FETCH_URI) puts @specific_version.nil? ? "Requesting latest setup script from #{uri.host}..." : "Requesting setup script version #{@specific_version.to_s.description} 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? postdata[:requested_script_version] = @specific_version.to_s.description unless @specific_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 if status_code >= 300 msg = "Failed to fetch setup script, status code: #{status_code}" if status_code >= 400 && status_code < 500 && !@specific_version.nil? raise msg + ". Version #{@specific_version.to_s.description} may not exist" else raise msg end end raise "Failed to fetch setup script" if res.body.nil? 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 raise e.message if e.message.include?("code: 4") 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)} MAVEN_ARTIFACT_IDS.each_with_index {|artifact_id, i| @repos[i][:artifact_id] = artifact_id} @repos[0][: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 => e raise "Failed to parse maven metadata received from server: #{e.message}" 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 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 end end end end repos = @repos 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? || version != Gem::Version.new(@version) 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 raise "Requested version too low: #{version}, aborting download" unless repo.size > 0 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) pod_zip 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.strict_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 exec_valid?(installer_exec) return false unless File.exist?(installer_exec) return false unless File.size(installer_exec) > 1000 true 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 additional_params += " -noupdater" if @noupdater 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) unless is_ok raise "Installer executable cannot be found. It may happen due to antivirus software blocking it" unless exec_valid?(installer_exec) raise "Failed to apply the #{SERVICE_NAME} Pod" end end end def move_build_root FileUtils.mv(BUILD_ROOT, BUILD_ROOT_TMP) rescue nil unless BUILD_ROOT.nil? 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 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) when "-noupdater" @noupdater = true 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 ==== 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 = get_pod_zip inflate_pod(pod_zip) else confirm_xcode_projects_exist unless @command == CMD_UNINSTALL pod_zip = 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) 2026 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 #MD5=dd470e6093b3c9035e92be102ff49c39