1#!/usr/bin/env python 2# Copyright (c) 2012 Google Inc. 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"""Utility functions to perform Xcode-style build steps. 7 8These functions are executed via gyp-mac-tool when using the Makefile generator. 9""" 10 11import fcntl 12import fnmatch 13import glob 14import json 15import os 16import plistlib 17import re 18import shutil 19import string 20import subprocess 21import sys 22import tempfile 23 24 25def main(args): 26 executor = MacTool() 27 exit_code = executor.Dispatch(args) 28 if exit_code is not None: 29 sys.exit(exit_code) 30 31 32class MacTool(object): 33 """This class performs all the Mac tooling steps. The methods can either be 34 executed directly, or dispatched from an argument list.""" 35 36 def Dispatch(self, args): 37 """Dispatches a string command to a method.""" 38 if len(args) < 1: 39 raise Exception("Not enough arguments") 40 41 method = "Exec%s" % self._CommandifyName(args[0]) 42 return getattr(self, method)(*args[1:]) 43 44 def _CommandifyName(self, name_string): 45 """Transforms a tool name like copy-info-plist to CopyInfoPlist""" 46 return name_string.title().replace('-', '') 47 48 def ExecCopyBundleResource(self, source, dest): 49 """Copies a resource file to the bundle/Resources directory, performing any 50 necessary compilation on each resource.""" 51 extension = os.path.splitext(source)[1].lower() 52 if os.path.isdir(source): 53 # Copy tree. 54 # TODO(thakis): This copies file attributes like mtime, while the 55 # single-file branch below doesn't. This should probably be changed to 56 # be consistent with the single-file branch. 57 if os.path.exists(dest): 58 shutil.rmtree(dest) 59 shutil.copytree(source, dest) 60 elif extension == '.xib': 61 return self._CopyXIBFile(source, dest) 62 elif extension == '.storyboard': 63 return self._CopyXIBFile(source, dest) 64 elif extension == '.strings': 65 self._CopyStringsFile(source, dest) 66 else: 67 shutil.copy(source, dest) 68 69 def _CopyXIBFile(self, source, dest): 70 """Compiles a XIB file with ibtool into a binary plist in the bundle.""" 71 72 # ibtool sometimes crashes with relative paths. See crbug.com/314728. 73 base = os.path.dirname(os.path.realpath(__file__)) 74 if os.path.relpath(source): 75 source = os.path.join(base, source) 76 if os.path.relpath(dest): 77 dest = os.path.join(base, dest) 78 79 args = ['xcrun', 'ibtool', '--errors', '--warnings', '--notices', 80 '--output-format', 'human-readable-text', '--compile', dest, source] 81 ibtool_section_re = re.compile(r'/\*.*\*/') 82 ibtool_re = re.compile(r'.*note:.*is clipping its content') 83 ibtoolout = subprocess.Popen(args, stdout=subprocess.PIPE) 84 current_section_header = None 85 for line in ibtoolout.stdout: 86 if ibtool_section_re.match(line): 87 current_section_header = line 88 elif not ibtool_re.match(line): 89 if current_section_header: 90 sys.stdout.write(current_section_header) 91 current_section_header = None 92 sys.stdout.write(line) 93 return ibtoolout.returncode 94 95 def _CopyStringsFile(self, source, dest): 96 """Copies a .strings file using iconv to reconvert the input into UTF-16.""" 97 input_code = self._DetectInputEncoding(source) or "UTF-8" 98 99 # Xcode's CpyCopyStringsFile / builtin-copyStrings seems to call 100 # CFPropertyListCreateFromXMLData() behind the scenes; at least it prints 101 # CFPropertyListCreateFromXMLData(): Old-style plist parser: missing 102 # semicolon in dictionary. 103 # on invalid files. Do the same kind of validation. 104 import CoreFoundation 105 s = open(source, 'rb').read() 106 d = CoreFoundation.CFDataCreate(None, s, len(s)) 107 _, error = CoreFoundation.CFPropertyListCreateFromXMLData(None, d, 0, None) 108 if error: 109 return 110 111 fp = open(dest, 'wb') 112 fp.write(s.decode(input_code).encode('UTF-16')) 113 fp.close() 114 115 def _DetectInputEncoding(self, file_name): 116 """Reads the first few bytes from file_name and tries to guess the text 117 encoding. Returns None as a guess if it can't detect it.""" 118 fp = open(file_name, 'rb') 119 try: 120 header = fp.read(3) 121 except e: 122 fp.close() 123 return None 124 fp.close() 125 if header.startswith("\xFE\xFF"): 126 return "UTF-16" 127 elif header.startswith("\xFF\xFE"): 128 return "UTF-16" 129 elif header.startswith("\xEF\xBB\xBF"): 130 return "UTF-8" 131 else: 132 return None 133 134 def ExecCopyInfoPlist(self, source, dest, *keys): 135 """Copies the |source| Info.plist to the destination directory |dest|.""" 136 # Read the source Info.plist into memory. 137 fd = open(source, 'r') 138 lines = fd.read() 139 fd.close() 140 141 # Insert synthesized key/value pairs (e.g. BuildMachineOSBuild). 142 plist = plistlib.readPlistFromString(lines) 143 if keys: 144 plist = dict(plist.items() + json.loads(keys[0]).items()) 145 lines = plistlib.writePlistToString(plist) 146 147 # Go through all the environment variables and replace them as variables in 148 # the file. 149 IDENT_RE = re.compile('[/\s]') 150 for key in os.environ: 151 if key.startswith('_'): 152 continue 153 evar = '${%s}' % key 154 evalue = os.environ[key] 155 lines = string.replace(lines, evar, evalue) 156 157 # Xcode supports various suffices on environment variables, which are 158 # all undocumented. :rfc1034identifier is used in the standard project 159 # template these days, and :identifier was used earlier. They are used to 160 # convert non-url characters into things that look like valid urls -- 161 # except that the replacement character for :identifier, '_' isn't valid 162 # in a URL either -- oops, hence :rfc1034identifier was born. 163 evar = '${%s:identifier}' % key 164 evalue = IDENT_RE.sub('_', os.environ[key]) 165 lines = string.replace(lines, evar, evalue) 166 167 evar = '${%s:rfc1034identifier}' % key 168 evalue = IDENT_RE.sub('-', os.environ[key]) 169 lines = string.replace(lines, evar, evalue) 170 171 # Remove any keys with values that haven't been replaced. 172 lines = lines.split('\n') 173 for i in range(len(lines)): 174 if lines[i].strip().startswith("<string>${"): 175 lines[i] = None 176 lines[i - 1] = None 177 lines = '\n'.join(filter(lambda x: x is not None, lines)) 178 179 # Write out the file with variables replaced. 180 fd = open(dest, 'w') 181 fd.write(lines) 182 fd.close() 183 184 # Now write out PkgInfo file now that the Info.plist file has been 185 # "compiled". 186 self._WritePkgInfo(dest) 187 188 def _WritePkgInfo(self, info_plist): 189 """This writes the PkgInfo file from the data stored in Info.plist.""" 190 plist = plistlib.readPlist(info_plist) 191 if not plist: 192 return 193 194 # Only create PkgInfo for executable types. 195 package_type = plist['CFBundlePackageType'] 196 if package_type != 'APPL': 197 return 198 199 # The format of PkgInfo is eight characters, representing the bundle type 200 # and bundle signature, each four characters. If that is missing, four 201 # '?' characters are used instead. 202 signature_code = plist.get('CFBundleSignature', '????') 203 if len(signature_code) != 4: # Wrong length resets everything, too. 204 signature_code = '?' * 4 205 206 dest = os.path.join(os.path.dirname(info_plist), 'PkgInfo') 207 fp = open(dest, 'w') 208 fp.write('%s%s' % (package_type, signature_code)) 209 fp.close() 210 211 def ExecFlock(self, lockfile, *cmd_list): 212 """Emulates the most basic behavior of Linux's flock(1).""" 213 # Rely on exception handling to report errors. 214 fd = os.open(lockfile, os.O_RDONLY|os.O_NOCTTY|os.O_CREAT, 0o666) 215 fcntl.flock(fd, fcntl.LOCK_EX) 216 return subprocess.call(cmd_list) 217 218 def ExecFilterLibtool(self, *cmd_list): 219 """Calls libtool and filters out '/path/to/libtool: file: foo.o has no 220 symbols'.""" 221 libtool_re = re.compile(r'^.*libtool: file: .* has no symbols$') 222 libtool_re5 = re.compile( 223 r'^.*libtool: warning for library: ' + 224 r'.* the table of contents is empty ' + 225 r'\(no object file members in the library define global symbols\)$') 226 libtoolout = subprocess.Popen(cmd_list, stderr=subprocess.PIPE) 227 _, err = libtoolout.communicate() 228 for line in err.splitlines(): 229 if not libtool_re.match(line) and not libtool_re5.match(line): 230 print >>sys.stderr, line 231 return libtoolout.returncode 232 233 def ExecPackageFramework(self, framework, version): 234 """Takes a path to Something.framework and the Current version of that and 235 sets up all the symlinks.""" 236 # Find the name of the binary based on the part before the ".framework". 237 binary = os.path.basename(framework).split('.')[0] 238 239 CURRENT = 'Current' 240 RESOURCES = 'Resources' 241 VERSIONS = 'Versions' 242 243 if not os.path.exists(os.path.join(framework, VERSIONS, version, binary)): 244 # Binary-less frameworks don't seem to contain symlinks (see e.g. 245 # chromium's out/Debug/org.chromium.Chromium.manifest/ bundle). 246 return 247 248 # Move into the framework directory to set the symlinks correctly. 249 pwd = os.getcwd() 250 os.chdir(framework) 251 252 # Set up the Current version. 253 self._Relink(version, os.path.join(VERSIONS, CURRENT)) 254 255 # Set up the root symlinks. 256 self._Relink(os.path.join(VERSIONS, CURRENT, binary), binary) 257 self._Relink(os.path.join(VERSIONS, CURRENT, RESOURCES), RESOURCES) 258 259 # Back to where we were before! 260 os.chdir(pwd) 261 262 def _Relink(self, dest, link): 263 """Creates a symlink to |dest| named |link|. If |link| already exists, 264 it is overwritten.""" 265 if os.path.lexists(link): 266 os.remove(link) 267 os.symlink(dest, link) 268 269 def ExecCodeSignBundle(self, key, resource_rules, entitlements, provisioning): 270 """Code sign a bundle. 271 272 This function tries to code sign an iOS bundle, following the same 273 algorithm as Xcode: 274 1. copy ResourceRules.plist from the user or the SDK into the bundle, 275 2. pick the provisioning profile that best match the bundle identifier, 276 and copy it into the bundle as embedded.mobileprovision, 277 3. copy Entitlements.plist from user or SDK next to the bundle, 278 4. code sign the bundle. 279 """ 280 resource_rules_path = self._InstallResourceRules(resource_rules) 281 substitutions, overrides = self._InstallProvisioningProfile( 282 provisioning, self._GetCFBundleIdentifier()) 283 entitlements_path = self._InstallEntitlements( 284 entitlements, substitutions, overrides) 285 subprocess.check_call([ 286 'codesign', '--force', '--sign', key, '--resource-rules', 287 resource_rules_path, '--entitlements', entitlements_path, 288 os.path.join( 289 os.environ['TARGET_BUILD_DIR'], 290 os.environ['FULL_PRODUCT_NAME'])]) 291 292 def _InstallResourceRules(self, resource_rules): 293 """Installs ResourceRules.plist from user or SDK into the bundle. 294 295 Args: 296 resource_rules: string, optional, path to the ResourceRules.plist file 297 to use, default to "${SDKROOT}/ResourceRules.plist" 298 299 Returns: 300 Path to the copy of ResourceRules.plist into the bundle. 301 """ 302 source_path = resource_rules 303 target_path = os.path.join( 304 os.environ['BUILT_PRODUCTS_DIR'], 305 os.environ['CONTENTS_FOLDER_PATH'], 306 'ResourceRules.plist') 307 if not source_path: 308 source_path = os.path.join( 309 os.environ['SDKROOT'], 'ResourceRules.plist') 310 shutil.copy2(source_path, target_path) 311 return target_path 312 313 def _InstallProvisioningProfile(self, profile, bundle_identifier): 314 """Installs embedded.mobileprovision into the bundle. 315 316 Args: 317 profile: string, optional, short name of the .mobileprovision file 318 to use, if empty or the file is missing, the best file installed 319 will be used 320 bundle_identifier: string, value of CFBundleIdentifier from Info.plist 321 322 Returns: 323 A tuple containing two dictionary: variables substitutions and values 324 to overrides when generating the entitlements file. 325 """ 326 source_path, provisioning_data, team_id = self._FindProvisioningProfile( 327 profile, bundle_identifier) 328 target_path = os.path.join( 329 os.environ['BUILT_PRODUCTS_DIR'], 330 os.environ['CONTENTS_FOLDER_PATH'], 331 'embedded.mobileprovision') 332 shutil.copy2(source_path, target_path) 333 substitutions = self._GetSubstitutions(bundle_identifier, team_id + '.') 334 return substitutions, provisioning_data['Entitlements'] 335 336 def _FindProvisioningProfile(self, profile, bundle_identifier): 337 """Finds the .mobileprovision file to use for signing the bundle. 338 339 Checks all the installed provisioning profiles (or if the user specified 340 the PROVISIONING_PROFILE variable, only consult it) and select the most 341 specific that correspond to the bundle identifier. 342 343 Args: 344 profile: string, optional, short name of the .mobileprovision file 345 to use, if empty or the file is missing, the best file installed 346 will be used 347 bundle_identifier: string, value of CFBundleIdentifier from Info.plist 348 349 Returns: 350 A tuple of the path to the selected provisioning profile, the data of 351 the embedded plist in the provisioning profile and the team identifier 352 to use for code signing. 353 354 Raises: 355 SystemExit: if no .mobileprovision can be used to sign the bundle. 356 """ 357 profiles_dir = os.path.join( 358 os.environ['HOME'], 'Library', 'MobileDevice', 'Provisioning Profiles') 359 if not os.path.isdir(profiles_dir): 360 print >>sys.stderr, ( 361 'cannot find mobile provisioning for %s' % bundle_identifier) 362 sys.exit(1) 363 provisioning_profiles = None 364 if profile: 365 profile_path = os.path.join(profiles_dir, profile + '.mobileprovision') 366 if os.path.exists(profile_path): 367 provisioning_profiles = [profile_path] 368 if not provisioning_profiles: 369 provisioning_profiles = glob.glob( 370 os.path.join(profiles_dir, '*.mobileprovision')) 371 valid_provisioning_profiles = {} 372 for profile_path in provisioning_profiles: 373 profile_data = self._LoadProvisioningProfile(profile_path) 374 app_id_pattern = profile_data.get( 375 'Entitlements', {}).get('application-identifier', '') 376 for team_identifier in profile_data.get('TeamIdentifier', []): 377 app_id = '%s.%s' % (team_identifier, bundle_identifier) 378 if fnmatch.fnmatch(app_id, app_id_pattern): 379 valid_provisioning_profiles[app_id_pattern] = ( 380 profile_path, profile_data, team_identifier) 381 if not valid_provisioning_profiles: 382 print >>sys.stderr, ( 383 'cannot find mobile provisioning for %s' % bundle_identifier) 384 sys.exit(1) 385 # If the user has multiple provisioning profiles installed that can be 386 # used for ${bundle_identifier}, pick the most specific one (ie. the 387 # provisioning profile whose pattern is the longest). 388 selected_key = max(valid_provisioning_profiles, key=lambda v: len(v)) 389 return valid_provisioning_profiles[selected_key] 390 391 def _LoadProvisioningProfile(self, profile_path): 392 """Extracts the plist embedded in a provisioning profile. 393 394 Args: 395 profile_path: string, path to the .mobileprovision file 396 397 Returns: 398 Content of the plist embedded in the provisioning profile as a dictionary. 399 """ 400 with tempfile.NamedTemporaryFile() as temp: 401 subprocess.check_call([ 402 'security', 'cms', '-D', '-i', profile_path, '-o', temp.name]) 403 return self._LoadPlistMaybeBinary(temp.name) 404 405 def _LoadPlistMaybeBinary(self, plist_path): 406 """Loads into a memory a plist possibly encoded in binary format. 407 408 This is a wrapper around plistlib.readPlist that tries to convert the 409 plist to the XML format if it can't be parsed (assuming that it is in 410 the binary format). 411 412 Args: 413 plist_path: string, path to a plist file, in XML or binary format 414 415 Returns: 416 Content of the plist as a dictionary. 417 """ 418 try: 419 # First, try to read the file using plistlib that only supports XML, 420 # and if an exception is raised, convert a temporary copy to XML and 421 # load that copy. 422 return plistlib.readPlist(plist_path) 423 except: 424 pass 425 with tempfile.NamedTemporaryFile() as temp: 426 shutil.copy2(plist_path, temp.name) 427 subprocess.check_call(['plutil', '-convert', 'xml1', temp.name]) 428 return plistlib.readPlist(temp.name) 429 430 def _GetSubstitutions(self, bundle_identifier, app_identifier_prefix): 431 """Constructs a dictionary of variable substitutions for Entitlements.plist. 432 433 Args: 434 bundle_identifier: string, value of CFBundleIdentifier from Info.plist 435 app_identifier_prefix: string, value for AppIdentifierPrefix 436 437 Returns: 438 Dictionary of substitutions to apply when generating Entitlements.plist. 439 """ 440 return { 441 'CFBundleIdentifier': bundle_identifier, 442 'AppIdentifierPrefix': app_identifier_prefix, 443 } 444 445 def _GetCFBundleIdentifier(self): 446 """Extracts CFBundleIdentifier value from Info.plist in the bundle. 447 448 Returns: 449 Value of CFBundleIdentifier in the Info.plist located in the bundle. 450 """ 451 info_plist_path = os.path.join( 452 os.environ['TARGET_BUILD_DIR'], 453 os.environ['INFOPLIST_PATH']) 454 info_plist_data = self._LoadPlistMaybeBinary(info_plist_path) 455 return info_plist_data['CFBundleIdentifier'] 456 457 def _InstallEntitlements(self, entitlements, substitutions, overrides): 458 """Generates and install the ${BundleName}.xcent entitlements file. 459 460 Expands variables "$(variable)" pattern in the source entitlements file, 461 add extra entitlements defined in the .mobileprovision file and the copy 462 the generated plist to "${BundlePath}.xcent". 463 464 Args: 465 entitlements: string, optional, path to the Entitlements.plist template 466 to use, defaults to "${SDKROOT}/Entitlements.plist" 467 substitutions: dictionary, variable substitutions 468 overrides: dictionary, values to add to the entitlements 469 470 Returns: 471 Path to the generated entitlements file. 472 """ 473 source_path = entitlements 474 target_path = os.path.join( 475 os.environ['BUILT_PRODUCTS_DIR'], 476 os.environ['PRODUCT_NAME'] + '.xcent') 477 if not source_path: 478 source_path = os.path.join( 479 os.environ['SDKROOT'], 480 'Entitlements.plist') 481 shutil.copy2(source_path, target_path) 482 data = self._LoadPlistMaybeBinary(target_path) 483 data = self._ExpandVariables(data, substitutions) 484 if overrides: 485 for key in overrides: 486 if key not in data: 487 data[key] = overrides[key] 488 plistlib.writePlist(data, target_path) 489 return target_path 490 491 def _ExpandVariables(self, data, substitutions): 492 """Expands variables "$(variable)" in data. 493 494 Args: 495 data: object, can be either string, list or dictionary 496 substitutions: dictionary, variable substitutions to perform 497 498 Returns: 499 Copy of data where each references to "$(variable)" has been replaced 500 by the corresponding value found in substitutions, or left intact if 501 the key was not found. 502 """ 503 if isinstance(data, str): 504 for key, value in substitutions.iteritems(): 505 data = data.replace('$(%s)' % key, value) 506 return data 507 if isinstance(data, list): 508 return [self._ExpandVariables(v, substitutions) for v in data] 509 if isinstance(data, dict): 510 return {k: self._ExpandVariables(data[k], substitutions) for k in data} 511 return data 512 513if __name__ == '__main__': 514 sys.exit(main(sys.argv[1:])) 515