lib/xcode/install.rb in xcode-install-2.3.1 vs lib/xcode/install.rb in xcode-install-2.4.0

- old
+ new

@@ -4,46 +4,120 @@ 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') - def fetch(url, directory = nil, cookies = nil, output = nil, progress = true) + # @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] - # options << ' -vvv' 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'] - progress = progress ? '--progress-bar' : '--silent' - command = ['curl', *options, *retry_options, '--location', '--continue-at', '-', progress, '--output', output, url].map(&:to_s) + 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 - io = IO.popen(command) - io.each { |line| puts line } - io.close + # 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) - exit_code = $?.exitstatus - return exit_code.zero? unless exit_code == 18 + # 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) @@ -55,21 +129,51 @@ 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 + 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 || xcode.url, CACHE_DIR, url ? nil : spaceship.cookie, dmg_file, progress) + 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) - list_versions.include?(version) + return true if find_xcode_version(version) + false end def installed?(version) installed_versions.map(&:version).include?(version) end @@ -78,10 +182,35 @@ 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" @@ -140,16 +269,17 @@ end FileUtils.rm_f(dmg_path) if clean end - def install_version(version, switch = true, clean = true, install = true, progress = true, url = nil, show_release_notes = true) - dmg_path = get_dmg(version, progress, url) + # 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.split(' ')[0]}", switch, clean) + 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 @@ -197,18 +327,16 @@ 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) + raise 'The specified Apple developer account credentials are incorrect.' rescue Spaceship::Client::NoUserCredentialsError - $stderr.puts <<-HELP + raise <<-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 @@ -223,21 +351,21 @@ 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) + 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) + download(version, progress, url, progress_block) end def fetch_seedlist @xcodes = parse_seedlist(spaceship.send(:request, :post, '/services-account/QH65B2/downloadws/listDownloads.action').body) @@ -252,12 +380,11 @@ xcodes end def installed unless (`mdutil -s /` =~ /disabled/).nil? - $stderr.puts 'Please enable Spotlight indexing for /Applications.' - exit(1) + raise 'Please enable Spotlight indexing for /Applications.' end `mdfind "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'" 2>/dev/null`.split("\n") end @@ -312,15 +439,10 @@ end links end - def seedlist - @xcodes = Marshal.load(File.read(LIST_FILE)) if LIST_FILE.exist? && xcodes.nil? - xcodes || fetch_seedlist - end - def verify_integrity(path) puts `/usr/sbin/spctl --assess --verbose=4 --type execute #{path}` $?.exitstatus.zero? end @@ -373,12 +495,17 @@ s.version == version end end end - def download(progress) - result = Curl.new.fetch(source, CACHE_DIR, nil, nil, progress) + 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) @@ -476,16 +603,20 @@ end end end def approve_license - 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}` + 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) @@ -508,29 +639,48 @@ 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 - :private - - def plist_entry(keypath) - `/usr/libexec/PlistBuddy -c "Print :#{keypath}" "#{path}/Contents/Info.plist"`.chomp - 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 /, '')