neonichu/xcode-install

View on GitHub
lib/xcode/install.rb

Summary

Maintainability
F
3 days
Test Coverage
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 'fastlane'
require 'fastlane/helper/sh_helper'
require 'fastlane/action'
require 'fastlane/actions/verify_xcode'

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)
    # @param retry_download_count: A count to retry the downloading Xcode dmg/xip
    def fetch(url: nil,
              directory: nil,
              cookies: nil,
              output: nil,
              progress: nil,
              progress_block: nil,
              retry_download_count: 3)
      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',
        '--disable',
        *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
      retry_download_count.times do
        wait_thr = poll_file(command_string: command_string, progress_log_file: progress_log_file, progress: progress, progress_block: progress_block)
        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

    def poll_file(command_string:, progress_log_file:, progress: nil, progress_block: nil)
      # 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 && 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

      wait_thr
    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, retry_download_count = 3)
      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,
        retry_download_count: retry_download_count
      )
      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
      end

      seedlist.each do |current_seed|
        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 { |seed| [seed.version, -seed.date_modified] }.reverse
    end

    def install_dmg(dmg_path, suffix = '', switch = true, clean = true)
      prompt = "Please authenticate for Xcode installation.\nPassword: "
      xcode_path = "/Applications/Xcode#{suffix}.app"

      if dmg_path.extname == '.xip'
        `xip -x #{dmg_path}`
        xcode_orig_path = File.join(Dir.pwd, 'Xcode.app')
        xcode_beta_path = File.join(Dir.pwd, '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

      xcode = InstalledXcode.new(xcode_path)

      unless xcode.verify_integrity
        `sudo rm -rf #{xcode_path}`
        return
      end

      enable_developer_mode
      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, retry_download_count = 3)
      dmg_path = get_dmg(version, progress, url, progress_block, retry_download_count)
      fail Informative, "Failed to download Xcode #{version}." if dmg_path.nil?

      if install
        install_dmg(dmg_path, "-#{version.to_s.split(' ').join('.')}", 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(&:appname_version)

      xcodes_list.map do |x|
        xcode_version = x.split(' ') # split version and "beta N", "for Lion"
        xcode_version[0] << '.0' unless xcode_version[0].include?('.')

        # to match InstalledXcode.appname_version format
        version = Gem::Version.new(xcode_version.join('.'))

        installed.include?(version) ? "#{x} (installed)" : x
      end.join("\n")
    end

    def list
      list_annotated(list_versions.sort { |first, second| compare_versions(first, second) })
    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, retry_download_count = 3)
      if url
        path = Pathname.new(url)
        return path if path.exist?
      end
      if ENV.key?('XCODE_INSTALL_CACHE_DIR')
        Pathname.glob(ENV['XCODE_INSTALL_CACHE_DIR'] + '/*').each do |fpath|
          return fpath if /^xcode_#{version}\.dmg|xip$/ =~ fpath.basename.to_s
        end
      end

      download(version, progress, url, progress_block, retry_download_count)
    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 -maxdepth 1 -name '*.app' -type d -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

    # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
    def compare_versions(first, second)
      # Sort by version number
      numeric_comparation = first.to_f <=> second.to_f
      return numeric_comparation if numeric_comparation != 0

      # Return beta versions before others
      is_first_beta = first.include?('beta')
      is_second_beta = second.include?('beta')
      return -1 if is_first_beta && !is_second_beta
      return 1 if !is_first_beta && is_second_beta

      # Return GM versions before others
      is_first_gm = first.include?('GM')
      is_second_gm = second.include?('GM')
      return -1 if is_first_gm && !is_second_gm
      return 1 if !is_first_gm && is_second_gm

      # Return Release Candidate versions before others
      is_first_rc = first.include?('RC') || first.include?('Release Candidate')
      is_second_rc = second.include?('RC') || second.include?('Release Candidate')
      return -1 if is_first_rc && !is_second_rc
      return 1 if !is_first_rc && is_second_rc

      # Sort alphabetically
      first <=> second
    end
    # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity

    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, retry_download_count = 3)
      result = Curl.new.fetch(
        url: source,
        directory: CACHE_DIR,
        progress: progress,
        progress_block: progress_block,
        retry_download_count: retry_download_count
      )
      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(bundle_version_string)
    end

    def appname_version
      appname = @path.basename('.app').to_s
      version_string = appname.split('-').last
      begin
        Gem::Version.new(version_string)
      rescue ArgumentError
        puts 'Unable to determine Xcode version from path name, installed list may not correctly identify installed betas'
        Gem::Version.new(nil)
      end
    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_info_path = File.join(@path, 'Contents/Resources/LicenseInfo.plist')
        license_id = `/usr/libexec/PlistBuddy -c 'Print :licenseID' #{license_info_path}`
        license_type = `/usr/libexec/PlistBuddy -c 'Print :licenseType' #{license_info_path}`
        license_plist_path = '/Library/Preferences/com.apple.dt.Xcode.plist'
        `sudo rm -rf #{license_plist_path}`
        if license_type == 'GM'
          `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 /usr/libexec/PlistBuddy -c "add :IDELastBetaLicenseAgreedTo string #{license_id}" #{license_plist_path}`
          `sudo /usr/libexec/PlistBuddy -c "add :IDEXcodeVersionForAgreedToBetaLicense string #{version}" #{license_plist_path}`
        end
      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

    def fetch_version
      output = `/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "#{@path}/Contents/version.plist"`
      return '0.0' if output.nil? || output.empty? # ¯\_(ツ)_/¯
      output.sub("\n", '')
    end

    def verify_integrity
      verify_app_security_assessment && verify_app_cert
    end

    :private

    def bundle_version_string
      digits = plist_entry(':DTXcode').to_i.to_s
      if digits.length < 3
        digits.split(//).join('.')
      else
        "#{digits[0..-3]}.#{digits[-2]}.#{digits[-1]}"
      end
    end

    def plist_entry(keypath)
      `/usr/libexec/PlistBuddy -c "Print :#{keypath}" "#{path}/Contents/Info.plist"`.chomp
    end

    def verify_app_security_assessment
      puts `/usr/bin/codesign --verify --verbose #{@path}`
      $?.exitstatus.zero?
    end

    def verify_app_cert
      Fastlane::Actions::VerifyXcodeAction.run(xcode_path: @path.to_s)
      true
    rescue
      false
    end
  end

  # A version of Xcode we fetched from the Apple Developer Portal
  # we can download & install.
  #
  # Sample object:
  # <XcodeInstall::Xcode:0x007fa1d451c390
  #    @date_modified=1573661580,
  #    @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 = DateTime.strptime(json['dateModified'], '%m/%d/%y %H:%M').strftime('%s').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,
          'dateModified' => '01/01/70 00:00',
          'files' => [{ 'remotePath' => url.split('=').last }],
          'release_notes_path' => release_notes_path)
    end
  end
end