Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding Command Line Tools Installer (for all versions) #378

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
190 changes: 181 additions & 9 deletions lib/xcode/install.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 }
Expand All @@ -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
Expand All @@ -331,7 +414,7 @@ def mount(dmg_path)
node.text
end

private
#private

def spaceship
@spaceship ||= begin
Expand All @@ -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')

Expand All @@ -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)
Expand All @@ -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?
Expand All @@ -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

Expand All @@ -444,10 +572,14 @@ def prereleases

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)
begin
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)
rescue StandardError => e
print "Error finding prerelease Xcode" + e
end
end

links
Expand Down Expand Up @@ -638,6 +770,11 @@ def available_simulators
return []
end

def available_command_line_tools
(spaceship.send(:request, :post,
'/services-account/QH65B2/downloadws/listDownloads.action').body)
end

def install_components
# starting with Xcode 9, we have `xcodebuild -runFirstLaunch` available to do package
# postinstalls using a documented option
Expand Down Expand Up @@ -757,4 +894,39 @@ def self.new_prerelease(version, url, release_notes_path)
'release_notes_path' => release_notes_path)
end
end
end

class CLTool
attr_reader :date_modified
attr_reader :name
attr_reader :path
attr_reader :url
attr_reader :version
attr_reader :release_notes_url

def initialize(json, url = nil, release_notes_url = nil)
if url.nil?
@date_modified = json['dateModified'].to_i
@name = json['name']
@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
end

def to_s
"#{name}"
end

def ==(other)
date_modified == other.date_modified && name == other.name && path == other.path && \
url == other.url && version == other.version
end
end
end
3 changes: 2 additions & 1 deletion lib/xcode/install/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ class Command < CLAide::Command
require 'xcode/install/list'
require 'xcode/install/select'
require 'xcode/install/selected'
require 'xcode/install/simulators'
require 'xcode/install/tools.rb'
require 'xcode/install/uninstall'
require 'xcode/install/update'
require 'xcode/install/simulators'

self.abstract_command = true
self.command = 'xcversion'
Expand Down
40 changes: 40 additions & 0 deletions lib/xcode/install/tools.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
require 'claide'

module XcodeInstall
class Command
class Tools < Command
self.command = 'tools'
self.summary = 'List or install Xcode CLI tools.'

def self.options
[['--install=name', 'Install CLI tools with the name specified'],
['--force', 'Install even if the same version is already installed.'],
['--no-install', 'Only download DMG, but do not install it.'],
['--no-progress', 'Don’t show download progress.']].concat(super)
end

def initialize(argv)
@install = argv.option('install')
@force = argv.flag?('force', false)
@should_install = argv.flag?('install', true)
@progress = argv.flag?('progress', true)
@installer = XcodeInstall::Installer.new
super
end

def run
@install ? install : list
end

:private

def install
@installer.install_tools(@install)
end

def list
puts @installer.toolslist
end
end
end
end
20 changes: 20 additions & 0 deletions spec/installer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,24 @@ module XcodeInstall
percentages.count.should.be.close(8, 4)
end
end

describe '#find_xcode_version' do
it 'should find the one with the matching name' do
installer = Installer.new

xcodes = [
XcodeInstall::Xcode.new('name' => '11.4 beta 2',
'files' => [{
'remotePath' => '/Developer_Tools/Xcode_11.4_beta_2/Xcode_11.4_beta_2.xip'
}]),
XcodeInstall::Xcode.new('name' => '11.4',
'files' => [{
'remotePath' => '/Developer_Tools/Xcode_11.4/Xcode_11.4.xip'
}])
]

installer.stubs(:fetch_seedlist).returns(xcodes)
installer.find_xcode_version("11.4").name.should.be.equal("11.4")
end
end
end
2 changes: 1 addition & 1 deletion xcode-install.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ Gem::Specification.new do |spec|
# contains spaceship, which is used for auth and dev portal interactions
spec.add_dependency 'fastlane', '>= 2.1.0', '< 3.0.0'

spec.add_development_dependency 'bundler', '~> 1.7'
spec.add_development_dependency 'bundler', '~> 2.0.2'
spec.add_development_dependency 'rake', '~> 10.0'
end