require 'fileutils' require 'pathname' require 'rexml/document' require 'spaceship' require 'json' require 'rubygems/version' require 'xcode/install/command' require 'xcode/install/version' module XcodeInstall CACHE_DIR = Pathname.new("#{ENV['HOME']}/Library/Caches/XcodeInstall") class Curl COOKIES_PATH = Pathname.new('/tmp/curl-cookies.txt') def fetch(url, directory = nil, cookies = nil, output = nil, progress = true) options = cookies.nil? ? [] : ['--cookie', cookies, '--cookie-jar', COOKIES_PATH] # options << ' -vvv' uri = URI.parse(url) output ||= File.basename(uri.path) output = (Pathname.new(directory) + Pathname.new(output)) if directory retry_options = ['--retry', '3'] progress = progress ? '--progress-bar' : '--silent' command = ['curl', *options, *retry_options, '--location', '--continue-at', '-', progress, '--output', output, url].map(&:to_s) # 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 io = IO.popen(command) io.each { |line| puts line } io.close exit_code = $?.exitstatus return exit_code.zero? unless exit_code == 18 end false ensure FileUtils.rm_f(COOKIES_PATH) end end 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) return unless url || exist?(version) xcode = seedlist.find { |x| x.name == version } unless url dmg_file = Pathname.new(File.basename(url || xcode.path)) result = Curl.new.fetch(url || xcode.url, CACHE_DIR, url ? nil : spaceship.cookie, dmg_file, progress) result ? CACHE_DIR + dmg_file : nil end def exist?(version) list_versions.include?(version) 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 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 def install_version(version, switch = true, clean = true, install = true, progress = true, url = nil) dmg_path = get_dmg(version, progress, url) fail Informative, "Failed to download Xcode #{version}." if dmg_path.nil? if install install_dmg(dmg_path, "-#{version.split(' ')[0]}", switch, clean) else puts "Downloaded Xcode #{version} to '#{dmg_path}'" end open_release_notes_url(version) unless 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_current safe_list_versions = list_versions.reject { |v| v.nil? || v.empty? } stable_majors = safe_list_versions.reject { |v| /beta/i =~ v }.map { |v| v.split('.')[0] }.map { |v| v.split(' ')[0] } latest_stable_major = stable_majors.select { |v| v.length == 1 }.uniq.sort.last.to_i current_versions = safe_list_versions.select { |v| v.split('.')[0].to_i >= latest_stable_major }.sort list_annotated(current_versions) end def list list_annotated(list_versions.sort) 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 $stderr.puts 'The specified Apple developer account credentials are incorrect.' exit(1) rescue Spaceship::Client::NoUserCredentialsError $stderr.puts <<-HELP Please provide your Apple developer account credentials via the XCODE_INSTALL_USER and XCODE_INSTALL_PASSWORD environment variables. HELP exit(1) 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) 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) end def fetch_seedlist @xcodes = parse_seedlist(spaceship.send(:request, :get, '/services-account/QH65B2/downloadws/listDownloads.action', start: '0', limit: '1000', sort: 'dateModified', dir: 'DESC', searchTextField: '', searchCategories: '', search: 'false').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 unless (`mdutil -s /` =~ /disabled/).nil? $stderr.puts 'Please enable Spotlight indexing for /Applications.' exit(1) end `mdfind "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'" 2>/dev/null`.split("\n") end def parse_seedlist(seedlist) 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{(.*)}) 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[2].strip.gsub(/.*Xcode /, ''), pre[0], pre[3]) } 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{