require 'fileutils' require 'pathname' require 'rexml/document' require 'spaceship' require 'json' require 'rubygems/version' require 'xcode/install/command' require 'xcode/install/version' require 'shellwords' require 'open3' require 'fileutils' module XcodeInstall CACHE_DIR = Pathname.new("#{ENV['HOME']}/Library/Caches/XcodeInstall") class Curl COOKIES_PATH = Pathname.new('/tmp/curl-cookies.txt') # @param url: The URL to download # @param directory: The directory to download this file into # @param cookies: Any cookies we should use for the download (used for auth with Apple) # @param output: A PathName for where we want to store the file # @param progress: parse and show the progress? # @param progress_block: A block that's called whenever we have an updated progress % # the parameter is a single number that's literally percent (e.g. 1, 50, 80 or 100) # rubocop:disable Metrics/AbcSize def fetch(url: nil, directory: nil, cookies: nil, output: nil, progress: nil, progress_block: nil) options = cookies.nil? ? [] : ['--cookie', cookies, '--cookie-jar', COOKIES_PATH] uri = URI.parse(url) output ||= File.basename(uri.path) output = (Pathname.new(directory) + Pathname.new(output)) if directory # Piping over all of stderr over to a temporary file # the file content looks like this: # 0 4766M 0 6835k 0 0 573k 0 2:21:58 0:00:11 2:21:47 902k # This way we can parse the current % # The header is # % Total % Received % Xferd Average Speed Time Time Time Current # # Discussion for this on GH: https://github.com/KrauseFx/xcode-install/issues/276 # It was not easily possible to reimplement the same system using built-in methods # especially when it comes to resuming downloads # Piping over stderror to Ruby directly didn't work, due to the lack of flushing # from curl. The only reasonable way to trigger this, is to pipe things directly into a # local file, and parse that, and just poll that. We could get real time updates using # the `tail` command or similar, however the download task is not time sensitive enough # to make this worth the extra complexity, that's why we just poll and # wait for the process to be finished progress_log_file = File.join(CACHE_DIR, "progress.#{Time.now.to_i}.progress") FileUtils.rm_f(progress_log_file) retry_options = ['--retry', '3'] command = [ 'curl', *options, *retry_options, '--location', '--continue-at', '-', '--output', output, url ].map(&:to_s) command_string = command.collect(&:shellescape).join(' ') command_string += " 2> #{progress_log_file}" # to not run shellescape on the `2>` # Run the curl command in a loop, retry when curl exit status is 18 # "Partial file. Only a part of the file was transferred." # https://curl.haxx.se/mail/archive-2008-07/0098.html # https://github.com/KrauseFx/xcode-install/issues/210 3.times do # Non-blocking call of Open3 # We're not using the block based syntax, as the bacon testing # library doesn't seem to support writing tests for it stdin, stdout, stderr, wait_thr = Open3.popen3(command_string) # Poll the file and see if we're done yet while wait_thr.alive? sleep(0.5) # it's not critical for this to be real-time next unless File.exist?(progress_log_file) # it might take longer for it to be created progress_content = File.read(progress_log_file).split("\r").last # Print out the progress for the CLI if progress print "\r#{progress_content}%" $stdout.flush end # Call back the block for other processes that might be interested matched = progress_content.match(/^\s*(\d+)/) next unless matched.length == 2 percent = matched[1].to_i progress_block.call(percent) if progress_block end # as we're not making use of the block-based syntax # we need to manually close those stdin.close stdout.close stderr.close return wait_thr.value.success? if wait_thr.value.success? end false ensure FileUtils.rm_f(COOKIES_PATH) FileUtils.rm_f(progress_log_file) end end # rubocop:disable Metrics/ClassLength class Installer attr_reader :xcodes def initialize FileUtils.mkdir_p(CACHE_DIR) end def cache_dir CACHE_DIR end def current_symlink File.symlink?(SYMLINK_PATH) ? SYMLINK_PATH : nil end def download(version, progress, url = nil, progress_block = nil) xcode = find_xcode_version(version) if url.nil? return if url.nil? && xcode.nil? dmg_file = Pathname.new(File.basename(url || xcode.path)) result = Curl.new.fetch( url: url || xcode.url, directory: CACHE_DIR, cookies: url ? nil : spaceship.cookie, output: dmg_file, progress: progress, progress_block: progress_block ) result ? CACHE_DIR + dmg_file : nil end def find_xcode_version(version) # By checking for the name and the version we have the best success rate # Sometimes the user might pass # "4.3 for Lion" # or they might pass an actual Gem::Version # Gem::Version.new("8.0.0") # which should automatically match with "Xcode 8" begin parsed_version = Gem::Version.new(version) rescue ArgumentError nil end seedlist.each do |current_seed| return current_seed if current_seed.name == version return current_seed if parsed_version && current_seed.version == parsed_version end nil end def exist?(version) return true if find_xcode_version(version) false end def installed?(version) installed_versions.map(&:version).include?(version) end def installed_versions installed.map { |x| InstalledXcode.new(x) }.sort do |a, b| Gem::Version.new(a.version) <=> Gem::Version.new(b.version) end end # Returns an array of `XcodeInstall::Xcode` # <XcodeInstall::Xcode:0x007fa1d451c390 # @date_modified=2015, # @name="6.4", # @path="/Developer_Tools/Xcode_6.4/Xcode_6.4.dmg", # @url= # "https://developer.apple.com/devcenter/download.action?path=/Developer_Tools/Xcode_6.4/Xcode_6.4.dmg", # @version=Gem::Version.new("6.4")>, # # the resulting list is sorted with the most recent release as first element def seedlist @xcodes = Marshal.load(File.read(LIST_FILE)) if LIST_FILE.exist? && xcodes.nil? all_xcodes = (xcodes || fetch_seedlist) # We have to set the `installed` value here, as we might still use # the cached list of available Xcode versions, but have a new Xcode # installed in the mean-time cached_installed_versions = installed_versions.map(&:bundle_version) all_xcodes.each do |current_xcode| current_xcode.installed = cached_installed_versions.include?(current_xcode.version) end all_xcodes.sort_by(&:version) end def install_dmg(dmg_path, suffix = '', switch = true, clean = true) archive_util = '/System/Library/CoreServices/Applications/Archive Utility.app/Contents/MacOS/Archive Utility' prompt = "Please authenticate for Xcode installation.\nPassword: " xcode_path = "/Applications/Xcode#{suffix}.app" if dmg_path.extname == '.xip' `'#{archive_util}' #{dmg_path}` xcode_orig_path = dmg_path.dirname + 'Xcode.app' xcode_beta_path = dmg_path.dirname + 'Xcode-beta.app' if Pathname.new(xcode_orig_path).exist? `sudo -p "#{prompt}" mv "#{xcode_orig_path}" "#{xcode_path}"` elsif Pathname.new(xcode_beta_path).exist? `sudo -p "#{prompt}" mv "#{xcode_beta_path}" "#{xcode_path}"` else out = <<-HELP No `Xcode.app(or Xcode-beta.app)` found in XIP. Please remove #{dmg_path} if you suspect a corrupted download or run `xcversion update` to see if the version you tried to install has been pulled by Apple. If none of this is true, please open a new GH issue. HELP $stderr.puts out.tr("\n", ' ') return end else mount_dir = mount(dmg_path) source = Dir.glob(File.join(mount_dir, 'Xcode*.app')).first if source.nil? out = <<-HELP No `Xcode.app` found in DMG. Please remove #{dmg_path} if you suspect a corrupted download or run `xcversion update` to see if the version you tried to install has been pulled by Apple. If none of this is true, please open a new GH issue. HELP $stderr.puts out.tr("\n", ' ') return end `sudo -p "#{prompt}" ditto "#{source}" "#{xcode_path}"` `umount "/Volumes/Xcode"` end unless verify_integrity(xcode_path) `sudo rm -rf #{xcode_path}` return end enable_developer_mode xcode = InstalledXcode.new(xcode_path) xcode.approve_license xcode.install_components if switch `sudo rm -f #{SYMLINK_PATH}` unless current_symlink.nil? `sudo ln -sf #{xcode_path} #{SYMLINK_PATH}` unless SYMLINK_PATH.exist? `sudo xcode-select --switch #{xcode_path}` puts `xcodebuild -version` end FileUtils.rm_f(dmg_path) if clean end # rubocop:disable Metrics/ParameterLists def install_version(version, switch = true, clean = true, install = true, progress = true, url = nil, show_release_notes = true, progress_block = nil) dmg_path = get_dmg(version, progress, url, progress_block) fail Informative, "Failed to download Xcode #{version}." if dmg_path.nil? if install install_dmg(dmg_path, "-#{version.to_s.split(' ')[0]}", switch, clean) else puts "Downloaded Xcode #{version} to '#{dmg_path}'" end open_release_notes_url(version) if show_release_notes && !url end def open_release_notes_url(version) return if version.nil? xcode = seedlist.find { |x| x.name == version } `open #{xcode.release_notes_url}` unless xcode.nil? || xcode.release_notes_url.nil? end def list_annotated(xcodes_list) installed = installed_versions.map(&:version) xcodes_list.map { |x| installed.include?(x) ? "#{x} (installed)" : x }.join("\n") end def list list_annotated(list_versions.sort_by(&:to_f)) end def rm_list_cache FileUtils.rm_f(LIST_FILE) end def symlink(version) xcode = installed_versions.find { |x| x.version == version } `sudo rm -f #{SYMLINK_PATH}` unless current_symlink.nil? `sudo ln -sf #{xcode.path} #{SYMLINK_PATH}` unless xcode.nil? || SYMLINK_PATH.exist? end def symlinks_to File.absolute_path(File.readlink(current_symlink), SYMLINK_PATH.dirname) if current_symlink end def mount(dmg_path) plist = hdiutil('mount', '-plist', '-nobrowse', '-noverify', dmg_path.to_s) document = REXML::Document.new(plist) node = REXML::XPath.first(document, "//key[.='mount-point']/following-sibling::*[1]") fail Informative, 'Failed to mount image.' unless node node.text end private def spaceship @spaceship ||= begin begin Spaceship.login(ENV['XCODE_INSTALL_USER'], ENV['XCODE_INSTALL_PASSWORD']) rescue Spaceship::Client::InvalidUserCredentialsError raise 'The specified Apple developer account credentials are incorrect.' rescue Spaceship::Client::NoUserCredentialsError raise <<-HELP Please provide your Apple developer account credentials via the XCODE_INSTALL_USER and XCODE_INSTALL_PASSWORD environment variables. HELP end if ENV.key?('XCODE_INSTALL_TEAM_ID') Spaceship.client.team_id = ENV['XCODE_INSTALL_TEAM_ID'] end Spaceship.client end end LIST_FILE = CACHE_DIR + Pathname.new('xcodes.bin') MINIMUM_VERSION = Gem::Version.new('4.3') SYMLINK_PATH = Pathname.new('/Applications/Xcode.app') def enable_developer_mode `sudo /usr/sbin/DevToolsSecurity -enable` `sudo /usr/sbin/dseditgroup -o edit -t group -a staff _developer` end def get_dmg(version, progress = true, url = nil, progress_block = nil) if url path = Pathname.new(url) return path if path.exist? end if ENV.key?('XCODE_INSTALL_CACHE_DIR') cache_path = Pathname.new(ENV['XCODE_INSTALL_CACHE_DIR']) + Pathname.new("xcode-#{version}.dmg") return cache_path if cache_path.exist? end download(version, progress, url, progress_block) end def fetch_seedlist @xcodes = parse_seedlist(spaceship.send(:request, :post, '/services-account/QH65B2/downloadws/listDownloads.action').body) names = @xcodes.map(&:name) @xcodes += prereleases.reject { |pre| names.include?(pre.name) } File.open(LIST_FILE, 'wb') do |f| f << Marshal.dump(xcodes) end xcodes end def installed result = `mdfind "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'" 2>/dev/null`.split("\n") if result.empty? result = `find /Applications -name '*.app' -type d -maxdepth 1 -exec sh -c \ 'if [ "$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" \ "{}/Contents/Info.plist" 2>/dev/null)" == "com.apple.dt.Xcode" ]; then echo "{}"; fi' ';'`.split("\n") end result end def parse_seedlist(seedlist) fail Informative, seedlist['resultString'] unless seedlist['resultCode'].eql? 0 seeds = Array(seedlist['downloads']).select do |t| /^Xcode [0-9]/.match(t['name']) end xcodes = seeds.map { |x| Xcode.new(x) }.reject { |x| x.version < MINIMUM_VERSION }.sort do |a, b| a.date_modified <=> b.date_modified end xcodes.select { |x| x.url.end_with?('.dmg') || x.url.end_with?('.xip') } end def list_versions seedlist.map(&:name) end def prereleases body = spaceship.send(:request, :get, '/download/').body links = body.scan(%r{<a.+?href="(.+?/Xcode.+?/Xcode_(.+?)\.(dmg|xip))".*>(.*)</a>}) links = links.map do |link| parent = link[0].scan(%r{path=(/.*/.*/)}).first.first match = body.scan(/#{Regexp.quote(parent)}(.+?.pdf)/).first if match link + [parent + match.first] else link + [nil] end end links = links.map { |pre| Xcode.new_prerelease(pre[1].strip.tr('_', ' '), pre[0], pre[4]) } if links.count.zero? rg = %r{platform-title.*Xcode.* beta.*<\/p>} scan = body.scan(rg) if scan.count.zero? rg = %r{Xcode.* GM.*<\/p>} scan = body.scan(rg) end return [] if scan.empty? version = scan.first.gsub(/<.*?>/, '').gsub(/.*Xcode /, '') link = body.scan(%r{<button .*"(.+?.(dmg|xip))".*</button>}).first.first notes = body.scan(%r{<a.+?href="(/go/\?id=xcode-.+?)".*>(.*)</a>}).first.first links << Xcode.new(version, link, notes) end links end def verify_integrity(path) puts `/usr/sbin/spctl --assess --verbose=4 --type execute #{path}` $?.exitstatus.zero? end def hdiutil(*args) io = IO.popen(['hdiutil', *args]) result = io.read io.close unless $?.exitstatus.zero? file_path = args[-1] if `file -b #{file_path}`.start_with?('HTML') fail Informative, "Failed to mount #{file_path}, logging into your account from a browser should tell you what is going wrong." end fail Informative, 'Failed to invoke hdiutil.' end result end end class Simulator attr_reader :version attr_reader :name attr_reader :identifier attr_reader :source attr_reader :xcode def initialize(downloadable) @version = Gem::Version.new(downloadable['version']) @install_prefix = apply_variables(downloadable['userInfo']['InstallPrefix']) @name = apply_variables(downloadable['name']) @identifier = apply_variables(downloadable['identifier']) @source = apply_variables(downloadable['source']) end def installed? # FIXME: use downloadables' `InstalledIfAllReceiptsArePresentOrNewer` key File.directory?(@install_prefix) end def installed_string installed? ? 'installed' : 'not installed' end def to_s "#{name} (#{installed_string})" end def xcode Installer.new.installed_versions.find do |x| x.available_simulators.find do |s| s.version == version end end end def download(progress, progress_block = nil) result = Curl.new.fetch( url: source, directory: CACHE_DIR, progress: progress, progress_block: progress_block ) result ? dmg_path : nil end def install(progress, should_install) dmg_path = download(progress) fail Informative, "Failed to download #{@name}." if dmg_path.nil? return unless should_install prepare_package unless pkg_path.exist? puts "Please authenticate to install #{name}..." `sudo installer -pkg #{pkg_path} -target /` fail Informative, "Could not install #{name}, please try again" unless installed? source_receipts_dir = '/private/var/db/receipts' target_receipts_dir = "#{@install_prefix}/System/Library/Receipts" FileUtils.mkdir_p(target_receipts_dir) FileUtils.cp("#{source_receipts_dir}/#{@identifier}.bom", target_receipts_dir) FileUtils.cp("#{source_receipts_dir}/#{@identifier}.plist", target_receipts_dir) puts "Successfully installed #{name}" end :private def prepare_package puts 'Mounting DMG' mount_location = Installer.new.mount(dmg_path) puts 'Expanding pkg' expanded_pkg_path = CACHE_DIR + identifier FileUtils.rm_rf(expanded_pkg_path) `pkgutil --expand #{mount_location}/*.pkg #{expanded_pkg_path}` puts "Expanded pkg into #{expanded_pkg_path}" puts 'Unmounting DMG' `umount #{mount_location}` puts 'Setting package installation location' package_info_path = expanded_pkg_path + 'PackageInfo' package_info_contents = File.read(package_info_path) File.open(package_info_path, 'w') do |f| f << package_info_contents.sub('pkg-info', %(pkg-info install-location="#{@install_prefix}")) end puts 'Rebuilding package' `pkgutil --flatten #{expanded_pkg_path} #{pkg_path}` FileUtils.rm_rf(expanded_pkg_path) end def dmg_path CACHE_DIR + Pathname.new(source).basename end def pkg_path CACHE_DIR + "#{identifier}.pkg" end def apply_variables(template) variable_map = { '$(DOWNLOADABLE_VERSION_MAJOR)' => version.to_s.split('.')[0], '$(DOWNLOADABLE_VERSION_MINOR)' => version.to_s.split('.')[1], '$(DOWNLOADABLE_IDENTIFIER)' => identifier, '$(DOWNLOADABLE_VERSION)' => version.to_s }.freeze variable_map.each do |key, value| next unless template.include?(key) template.sub!(key, value) end template end end class InstalledXcode attr_reader :path attr_reader :version attr_reader :bundle_version attr_reader :uuid attr_reader :downloadable_index_url attr_reader :available_simulators def initialize(path) @path = Pathname.new(path) end def version @version ||= fetch_version end def bundle_version @bundle_version ||= Gem::Version.new(plist_entry(':DTXcode').to_i.to_s.split(//).join('.')) end def uuid @uuid ||= plist_entry(':DVTPlugInCompatibilityUUID') end def downloadable_index_url @downloadable_index_url ||= begin if Gem::Version.new(version) >= Gem::Version.new('8.1') "https://devimages-cdn.apple.com/downloads/xcode/simulators/index-#{bundle_version}-#{uuid}.dvtdownloadableindex" else "https://devimages.apple.com.edgekey.net/downloads/xcode/simulators/index-#{bundle_version}-#{uuid}.dvtdownloadableindex" end end end def approve_license if Gem::Version.new(version) < Gem::Version.new('7.3') license_path = "#{@path}/Contents/Resources/English.lproj/License.rtf" license_id = IO.read(license_path).match(/\bEA\d{4}\b/) license_plist_path = '/Library/Preferences/com.apple.dt.Xcode.plist' `sudo rm -rf #{license_plist_path}` `sudo /usr/libexec/PlistBuddy -c "add :IDELastGMLicenseAgreedTo string #{license_id}" #{license_plist_path}` `sudo /usr/libexec/PlistBuddy -c "add :IDEXcodeVersionForAgreedToGMLicense string #{@version}" #{license_plist_path}` else `sudo #{@path}/Contents/Developer/usr/bin/xcodebuild -license accept` end end def available_simulators @available_simulators ||= JSON.parse(`curl -Ls #{downloadable_index_url} | plutil -convert json -o - -`)['downloadables'].map do |downloadable| Simulator.new(downloadable) end rescue JSON::ParserError return [] end def install_components # starting with Xcode 9, we have `xcodebuild -runFirstLaunch` available to do package # postinstalls using a documented option if Gem::Version.new(version) >= Gem::Version.new('9') `sudo #{@path}/Contents/Developer/usr/bin/xcodebuild -runFirstLaunch` else Dir.glob("#{@path}/Contents/Resources/Packages/*.pkg").each do |pkg| `sudo installer -pkg #{pkg} -target /` end end osx_build_version = `sw_vers -buildVersion`.chomp tools_version = `/usr/libexec/PlistBuddy -c "Print :ProductBuildVersion" "#{@path}/Contents/version.plist"`.chomp cache_dir = `getconf DARWIN_USER_CACHE_DIR`.chomp `touch #{cache_dir}com.apple.dt.Xcode.InstallCheckCache_#{osx_build_version}_#{tools_version}` end # This method might take a few ms, this could be improved by implementing https://github.com/KrauseFx/xcode-install/issues/273 def fetch_version output = `DEVELOPER_DIR='' "#{@path}/Contents/Developer/usr/bin/xcodebuild" -version` return '0.0' if output.nil? || output.empty? # ¯\_(ツ)_/¯ output.split("\n").first.split(' ')[1] end :private def plist_entry(keypath) `/usr/libexec/PlistBuddy -c "Print :#{keypath}" "#{path}/Contents/Info.plist"`.chomp end end # A version of Xcode we fetched from the Apple Developer Portal # we can download & install. # # Sample object: # <XcodeInstall::Xcode:0x007fa1d451c390 # @date_modified=2015, # @name="6.4", # @path="/Developer_Tools/Xcode_6.4/Xcode_6.4.dmg", # @url= # "https://developer.apple.com/devcenter/download.action?path=/Developer_Tools/Xcode_6.4/Xcode_6.4.dmg", # @version=Gem::Version.new("6.4")>, class Xcode attr_reader :date_modified # The name might include extra information like "for Lion" or "beta 2" attr_reader :name attr_reader :path attr_reader :url attr_reader :version attr_reader :release_notes_url # Accessor since it's set by the `Installer` attr_accessor :installed alias installed? installed def initialize(json, url = nil, release_notes_url = nil) if url.nil? @date_modified = json['dateModified'].to_i @name = json['name'].gsub(/^Xcode /, '') @path = json['files'].first['remotePath'] url_prefix = 'https://developer.apple.com/devcenter/download.action?path=' @url = "#{url_prefix}#{@path}" @release_notes_url = "#{url_prefix}#{json['release_notes_path']}" if json['release_notes_path'] else @name = json @path = url.split('/').last url_prefix = 'https://developer.apple.com/' @url = "#{url_prefix}#{url}" @release_notes_url = "#{url_prefix}#{release_notes_url}" end begin @version = Gem::Version.new(@name.split(' ')[0]) rescue @version = Installer::MINIMUM_VERSION end end def to_s "Xcode #{version} -- #{url}" end def ==(other) date_modified == other.date_modified && name == other.name && path == other.path && \ url == other.url && version == other.version end def self.new_prerelease(version, url, release_notes_path) new('name' => version, 'files' => [{ 'remotePath' => url.split('=').last }], 'release_notes_path' => release_notes_path) end end end