diff --git a/README.md b/README.md index bb51d4cb..3456cd96 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ to a dialog popping up. Feel free to dupe [the radar][5]. 📡 XcodeInstall normally relies on the Spotlight index to locate installed versions of Xcode. If you use it while indexing is happening, it might show inaccurate results and it will not be able to see installed -versions on unindexed volumes. +versions on unindexed volumes. To workaround the Spotlight limitation, XcodeInstall searches `/Applications` folder to locate Xcodes when Spotlight is disabled on the machine, or when Spotlight query for Xcode does not return any results. But it still won't work if your Xcodes are not located under `/Applications` folder. diff --git a/lib/xcode/install.rb b/lib/xcode/install.rb index f3ce927d..c356f762 100644 --- a/lib/xcode/install.rb +++ b/lib/xcode/install.rb @@ -33,7 +33,6 @@ def fetch(url: 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 @@ -122,6 +121,7 @@ def fetch(url: nil, # rubocop:disable Metrics/ClassLength class Installer attr_reader :xcodes + attr_reader :tools def initialize FileUtils.mkdir_p(CACHE_DIR) @@ -152,6 +152,23 @@ def download(version, progress, url = nil, progress_block = nil) result ? CACHE_DIR + dmg_file : nil end + def download_tools(version, progress, url = nil, progress_block = nil) + tool = find_tools_version(version) if url.nil? + return if url.nil? && tool.nil? + + dmg_file = Pathname.new(File.basename(url || tool.path)) + + result = Curl.new.fetch( + url: url || tool.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 @@ -168,8 +185,21 @@ def find_xcode_version(version) 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 find_tools_version(version) + # Right now this only matches names exactly + # "Command Line Tools for Xcode 11.3.1" for example + toolslist.each do |current_tool| + return current_tool if current_tool.name == version + end nil end @@ -213,6 +243,11 @@ def seedlist all_xcodes.sort_by(&:version) end + def toolslist + @tools = Marshal.load(File.read(TOOLS_LIST_FILE)) if TOOLS_LIST_FILE.exist? && tools.nil? + all_tools = (tools || fetch_toolslist) + end + def install_dmg(dmg_path, suffix = '', switch = true, clean = true) prompt = "Please authenticate for Xcode installation.\nPassword: " xcode_path = "/Applications/Xcode#{suffix}.app" @@ -289,6 +324,50 @@ def install_version(version, switch = true, clean = true, install = true, progre open_release_notes_url(version) if show_release_notes && !url end + def install_tools(version, switch = true, clean = true, install = true, progress = true, url = nil, show_release_notes = true, progress_block = nil) + dmg_path = get_tools_dmg(version, progress, url, progress_block) + fail Informative, "Failed to download #{version}." if dmg_path.nil? + + if install + mount_dir = mount(dmg_path) + pkg_path = Dir.glob(File.join(mount_dir, '*.pkg')).first + + # macOS 10.9 and above use a different type of package + # TODO: Add checks for 10.9 and below + macos_version = `sw_vers -productVersion`.strip.split('.') + if (macos_version[0].to_i == 10 && macos_version[1].to_i > 9) + `pkgutil --expand "#{pkg_path}" #{CACHE_DIR}/clt` + + target_version_xml = REXML::Document.new(`cat #{CACHE_DIR}/clt/CLTools_Executables.pkg/PackageInfo`) + target_version = target_version_xml.root.attributes["version"] + + puts("Installing version #{target_version} from #{pkg_path}") + else + puts("Installing from #{pkg_path}") + end + + prompt = "Please authenticate to install Command Line Tools.\nPassword: " + `sudo -p "#{prompt}" installer -verbose -pkg "#{pkg_path}" -target /` + `umount "#{mount_dir}"` + + if (macos_version[0].to_i == 10 && macos_version[1].to_i > 9) + installed_version = `pkgutil --pkg-info=com.apple.pkg.CLTools_Executables | grep version`.strip.sub("version: ", "") + if (installed_version == target_version) + puts "Command Line Tools version #{installed_version} installed successfully" + else + puts "Error installing Command Line Tools" + end + else + puts "Installed Command Line Tools" + end + + `rm -rf #{CACHE_DIR}/clt` + + else + puts "Downloaded #{version} to '#{dmg_path}'" + end + end + def open_release_notes_url(version) return if version.nil? xcode = seedlist.find { |x| x.name == version } @@ -309,6 +388,10 @@ def list list_annotated(list_versions.sort_by(&:to_f)) end + def list_tools + list_tools_versions.sort_by(&:to_f) + end + def rm_list_cache FileUtils.rm_f(LIST_FILE) end @@ -331,7 +414,7 @@ def mount(dmg_path) node.text end - private + #private def spaceship @spaceship ||= begin @@ -354,6 +437,7 @@ def spaceship end LIST_FILE = CACHE_DIR + Pathname.new('xcodes.bin') + TOOLS_LIST_FILE = CACHE_DIR + Pathname.new('tools.bin') MINIMUM_VERSION = Gem::Version.new('4.3') SYMLINK_PATH = Pathname.new('/Applications/Xcode.app') @@ -376,12 +460,26 @@ def get_dmg(version, progress = true, url = nil, progress_block = nil) download(version, progress, url, progress_block) end + def get_tools_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') + Pathname.glob(ENV['XCODE_INSTALL_CACHE_DIR'] + '/*').each do |fpath| + return fpath if /^#{version.tr(" ", "_")}\.dmg$/ =~ fpath.basename.to_s + end + end + + download_tools(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) } + # @xcodes += prereleases.reject { |pre| names.include?(pre.name) } File.open(LIST_FILE, 'wb') do |f| f << Marshal.dump(xcodes) @@ -390,6 +488,18 @@ def fetch_seedlist xcodes end + def fetch_toolslist + @tools = parse_toolslist(spaceship.send(:request, :post, + '/services-account/QH65B2/downloadws/listDownloads.action').body) + names = @tools.map(&:name) + + File.open(TOOLS_LIST_FILE, 'wb') do |f| + f << Marshal.dump(tools) + end + + tools + end + def installed result = `mdfind "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'" 2>/dev/null`.split("\n") if result.empty? @@ -414,10 +524,28 @@ def parse_seedlist(seedlist) xcodes.select { |x| x.url.end_with?('.dmg') || x.url.end_with?('.xip') } end + def parse_toolslist(toolslist) + fail Informative, toolslist['resultString'] unless toolslist['resultCode'].eql? 0 + + seeds = Array(toolslist['downloads']).select do |t| + /^Command Line Tools /.match(t['name']) + end + + tools = seeds.map { |x| CLTool.new(x) }.sort do |a, b| + a.date_modified <=> b.date_modified + end + + tools.select { |x| x.url.end_with?('.dmg') } + end + def list_versions seedlist.map(&:name) end + def list_tools_versions + toolslist.map(&name) + end + def prereleases body = spaceship.send(:request, :get, '/download/').body @@ -444,10 +572,14 @@ def prereleases return [] if scan.empty? - version = scan.first.gsub(/<.*?>/, '').gsub(/.*Xcode /, '') - link = body.scan(%r{