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