1#!/usr/bin/env python
2# Copyright 2016 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Download necessary mac toolchain files under certain conditions.  If
7xcode-select is already set and points to an external folder
8(e.g. /Application/Xcode.app), this script only runs if the GYP_DEFINE
9|force_mac_toolchain| is set.  To override the values in
10|TOOLCHAIN_REVISION|-|TOOLCHAIN_SUB_REVISION| below, GYP_DEFINE
11mac_toolchain_revision can be used instead.
12
13This script will only run on machines if /usr/bin/xcodebuild and
14/usr/bin/xcode-select has been added to the sudoers list so the license can be
15accepted.
16
17Otherwise, user input would be required to complete the script.  Perhaps future
18versions can be modified to allow for user input on developer machines.
19"""
20
21import os
22import plistlib
23import shutil
24import subprocess
25import sys
26import tarfile
27import time
28import tempfile
29import urllib2
30
31# This can be changed after running /build/package_mac_toolchain.py.
32TOOLCHAIN_REVISION = '5B1008'
33TOOLCHAIN_SUB_REVISION = 2
34TOOLCHAIN_VERSION = '%s-%s' % (TOOLCHAIN_REVISION, TOOLCHAIN_SUB_REVISION)
35
36BASE_DIR = os.path.abspath(os.path.dirname(__file__))
37TOOLCHAIN_BUILD_DIR = os.path.join(BASE_DIR, 'mac_files', 'Xcode.app')
38STAMP_FILE = os.path.join(BASE_DIR, 'mac_files', 'toolchain_build_revision')
39TOOLCHAIN_URL = 'gs://chrome-mac-sdk/'
40
41
42def GetToolchainDirectory():
43  if sys.platform == 'darwin' and not UseLocalMacSDK():
44    return TOOLCHAIN_BUILD_DIR
45  else:
46    return None
47
48
49def SetToolchainEnvironment():
50  mac_toolchain_dir = GetToolchainDirectory()
51  if mac_toolchain_dir:
52    os.environ['DEVELOPER_DIR'] = mac_toolchain_dir
53
54
55def ReadStampFile():
56  """Return the contents of the stamp file, or '' if it doesn't exist."""
57  try:
58    with open(STAMP_FILE, 'r') as f:
59      return f.read().rstrip()
60  except IOError:
61    return ''
62
63
64def WriteStampFile(s):
65  """Write s to the stamp file."""
66  EnsureDirExists(os.path.dirname(STAMP_FILE))
67  with open(STAMP_FILE, 'w') as f:
68    f.write(s)
69    f.write('\n')
70
71
72def EnsureDirExists(path):
73  if not os.path.exists(path):
74    os.makedirs(path)
75
76
77def DownloadAndUnpack(url, output_dir):
78  """Decompresses |url| into a cleared |output_dir|."""
79  temp_name = tempfile.mktemp(prefix='mac_toolchain')
80  try:
81    print 'Downloading new toolchain.'
82    subprocess.check_call(['gsutil.py', 'cp', url, temp_name])
83    if os.path.exists(output_dir):
84      print 'Deleting old toolchain.'
85      shutil.rmtree(output_dir)
86    EnsureDirExists(output_dir)
87    print 'Unpacking new toolchain.'
88    tarfile.open(mode='r:gz', name=temp_name).extractall(path=output_dir)
89  finally:
90    if os.path.exists(temp_name):
91      os.unlink(temp_name)
92
93
94def CanAccessToolchainBucket():
95  """Checks whether the user has access to |TOOLCHAIN_URL|."""
96  proc = subprocess.Popen(['gsutil.py', 'ls', TOOLCHAIN_URL],
97                           stdout=subprocess.PIPE)
98  proc.communicate()
99  return proc.returncode == 0
100
101def LoadPlist(path):
102  """Loads Plist at |path| and returns it as a dictionary."""
103  fd, name = tempfile.mkstemp()
104  try:
105    subprocess.check_call(['plutil', '-convert', 'xml1', '-o', name, path])
106    with os.fdopen(fd, 'r') as f:
107      return plistlib.readPlist(f)
108  finally:
109    os.unlink(name)
110
111
112def AcceptLicense():
113  """Use xcodebuild to accept new toolchain license if necessary.  Don't accept
114  the license if a newer license has already been accepted. This only works if
115  xcodebuild and xcode-select are passwordless in sudoers."""
116
117  # Check old license
118  try:
119    target_license_plist_path = \
120        os.path.join(TOOLCHAIN_BUILD_DIR,
121                     *['Contents','Resources','LicenseInfo.plist'])
122    target_license_plist = LoadPlist(target_license_plist_path)
123    build_type = target_license_plist['licenseType']
124    build_version = target_license_plist['licenseID']
125
126    accepted_license_plist = LoadPlist(
127        '/Library/Preferences/com.apple.dt.Xcode.plist')
128    agreed_to_key = 'IDELast%sLicenseAgreedTo' % build_type
129    last_license_agreed_to = accepted_license_plist[agreed_to_key]
130
131    # Historically all Xcode build numbers have been in the format of AANNNN, so
132    # a simple string compare works.  If Xcode's build numbers change this may
133    # need a more complex compare.
134    if build_version <= last_license_agreed_to:
135      # Don't accept the license of older toolchain builds, this will break the
136      # license of newer builds.
137      return
138  except (subprocess.CalledProcessError, KeyError):
139    # If there's never been a license of type |build_type| accepted,
140    # |target_license_plist_path| or |agreed_to_key| may not exist.
141    pass
142
143  print "Accepting license."
144  old_path = subprocess.Popen(['/usr/bin/xcode-select', '-p'],
145                               stdout=subprocess.PIPE).communicate()[0].strip()
146  try:
147    build_dir = os.path.join(TOOLCHAIN_BUILD_DIR, 'Contents/Developer')
148    subprocess.check_call(['sudo', '/usr/bin/xcode-select', '-s', build_dir])
149    subprocess.check_call(['sudo', '/usr/bin/xcodebuild', '-license', 'accept'])
150  finally:
151    subprocess.check_call(['sudo', '/usr/bin/xcode-select', '-s', old_path])
152
153
154def UseLocalMacSDK():
155  force_pull = os.environ.has_key('FORCE_MAC_TOOLCHAIN')
156
157  # Don't update the toolchain if there's already one installed outside of the
158  # expected location for a Chromium mac toolchain, unless |force_pull| is set.
159  proc = subprocess.Popen(['xcode-select', '-p'], stdout=subprocess.PIPE)
160  xcode_select_dir = proc.communicate()[0]
161  rc = proc.returncode
162  return (not force_pull and rc == 0 and
163          TOOLCHAIN_BUILD_DIR not in xcode_select_dir)
164
165
166def main():
167  if sys.platform != 'darwin':
168    return 0
169
170  # TODO(justincohen): Add support for GN per crbug.com/570091
171  if UseLocalMacSDK():
172    print 'Using local toolchain.'
173    return 0
174
175  toolchain_revision = os.environ.get('MAC_TOOLCHAIN_REVISION',
176                                      TOOLCHAIN_VERSION)
177  if ReadStampFile() == toolchain_revision:
178    print 'Toolchain (%s) is already up to date.' % toolchain_revision
179    AcceptLicense()
180    return 0
181
182  if not CanAccessToolchainBucket():
183    print 'Cannot access toolchain bucket.'
184    return 0
185
186  # Reset the stamp file in case the build is unsuccessful.
187  WriteStampFile('')
188
189  toolchain_file = '%s.tgz' % toolchain_revision
190  toolchain_full_url = TOOLCHAIN_URL + toolchain_file
191
192  print 'Updating toolchain to %s...' % toolchain_revision
193  try:
194    toolchain_file = 'toolchain-%s.tgz' % toolchain_revision
195    toolchain_full_url = TOOLCHAIN_URL + toolchain_file
196    DownloadAndUnpack(toolchain_full_url, TOOLCHAIN_BUILD_DIR)
197    AcceptLicense()
198
199    print 'Toolchain %s unpacked.' % toolchain_revision
200    WriteStampFile(toolchain_revision)
201    return 0
202  except Exception as e:
203    print 'Failed to download toolchain %s.' % toolchain_file
204    print 'Exception %s' % e
205    print 'Exiting.'
206    return 1
207
208if __name__ == '__main__':
209  sys.exit(main())
210