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 8 These functions are executed via gyp-mac-tool when using the Makefile generator. 9 """ 10 11 import fcntl 12 import fnmatch 13 import glob 14 import json 15 import os 16 import plistlib 17 import re 18 import shutil 19 import string 20 import subprocess 21 import sys 22 import tempfile 23 24 25 def 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 32 class 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 libtoolout = subprocess.Popen(cmd_list, stderr=subprocess.PIPE) 223 _, err = libtoolout.communicate() 224 for line in err.splitlines(): 225 if not libtool_re.match(line): 226 print >>sys.stderr, line 227 return libtoolout.returncode 228 229 def ExecPackageFramework(self, framework, version): 230 """Takes a path to Something.framework and the Current version of that and 231 sets up all the symlinks.""" 232 # Find the name of the binary based on the part before the ".framework". 233 binary = os.path.basename(framework).split('.')[0] 234 235 CURRENT = 'Current' 236 RESOURCES = 'Resources' 237 VERSIONS = 'Versions' 238 239 if not os.path.exists(os.path.join(framework, VERSIONS, version, binary)): 240 # Binary-less frameworks don't seem to contain symlinks (see e.g. 241 # chromium's out/Debug/org.chromium.Chromium.manifest/ bundle). 242 return 243 244 # Move into the framework directory to set the symlinks correctly. 245 pwd = os.getcwd() 246 os.chdir(framework) 247 248 # Set up the Current version. 249 self._Relink(version, os.path.join(VERSIONS, CURRENT)) 250 251 # Set up the root symlinks. 252 self._Relink(os.path.join(VERSIONS, CURRENT, binary), binary) 253 self._Relink(os.path.join(VERSIONS, CURRENT, RESOURCES), RESOURCES) 254 255 # Back to where we were before! 256 os.chdir(pwd) 257 258 def _Relink(self, dest, link): 259 """Creates a symlink to |dest| named |link|. If |link| already exists, 260 it is overwritten.""" 261 if os.path.lexists(link): 262 os.remove(link) 263 os.symlink(dest, link) 264 265 def ExecCodeSignBundle(self, key, resource_rules, entitlements, provisioning): 266 """Code sign a bundle. 267 268 This function tries to code sign an iOS bundle, following the same 269 algorithm as Xcode: 270 1. copy ResourceRules.plist from the user or the SDK into the bundle, 271 2. pick the provisioning profile that best match the bundle identifier, 272 and copy it into the bundle as embedded.mobileprovision, 273 3. copy Entitlements.plist from user or SDK next to the bundle, 274 4. code sign the bundle. 275 """ 276 resource_rules_path = self._InstallResourceRules(resource_rules) 277 substitutions, overrides = self._InstallProvisioningProfile( 278 provisioning, self._GetCFBundleIdentifier()) 279 entitlements_path = self._InstallEntitlements( 280 entitlements, substitutions, overrides) 281 subprocess.check_call([ 282 'codesign', '--force', '--sign', key, '--resource-rules', 283 resource_rules_path, '--entitlements', entitlements_path, 284 os.path.join( 285 os.environ['TARGET_BUILD_DIR'], 286 os.environ['FULL_PRODUCT_NAME'])]) 287 288 def _InstallResourceRules(self, resource_rules): 289 """Installs ResourceRules.plist from user or SDK into the bundle. 290 291 Args: 292 resource_rules: string, optional, path to the ResourceRules.plist file 293 to use, default to "${SDKROOT}/ResourceRules.plist" 294 295 Returns: 296 Path to the copy of ResourceRules.plist into the bundle. 297 """ 298 source_path = resource_rules 299 target_path = os.path.join( 300 os.environ['BUILT_PRODUCTS_DIR'], 301 os.environ['CONTENTS_FOLDER_PATH'], 302 'ResourceRules.plist') 303 if not source_path: 304 source_path = os.path.join( 305 os.environ['SDKROOT'], 'ResourceRules.plist') 306 shutil.copy2(source_path, target_path) 307 return target_path 308 309 def _InstallProvisioningProfile(self, profile, bundle_identifier): 310 """Installs embedded.mobileprovision into the bundle. 311 312 Args: 313 profile: string, optional, short name of the .mobileprovision file 314 to use, if empty or the file is missing, the best file installed 315 will be used 316 bundle_identifier: string, value of CFBundleIdentifier from Info.plist 317 318 Returns: 319 A tuple containing two dictionary: variables substitutions and values 320 to overrides when generating the entitlements file. 321 """ 322 source_path, provisioning_data, team_id = self._FindProvisioningProfile( 323 profile, bundle_identifier) 324 target_path = os.path.join( 325 os.environ['BUILT_PRODUCTS_DIR'], 326 os.environ['CONTENTS_FOLDER_PATH'], 327 'embedded.mobileprovision') 328 shutil.copy2(source_path, target_path) 329 substitutions = self._GetSubstitutions(bundle_identifier, team_id + '.') 330 return substitutions, provisioning_data['Entitlements'] 331 332 def _FindProvisioningProfile(self, profile, bundle_identifier): 333 """Finds the .mobileprovision file to use for signing the bundle. 334 335 Checks all the installed provisioning profiles (or if the user specified 336 the PROVISIONING_PROFILE variable, only consult it) and select the most 337 specific that correspond to the bundle identifier. 338 339 Args: 340 profile: string, optional, short name of the .mobileprovision file 341 to use, if empty or the file is missing, the best file installed 342 will be used 343 bundle_identifier: string, value of CFBundleIdentifier from Info.plist 344 345 Returns: 346 A tuple of the path to the selected provisioning profile, the data of 347 the embedded plist in the provisioning profile and the team identifier 348 to use for code signing. 349 350 Raises: 351 SystemExit: if no .mobileprovision can be used to sign the bundle. 352 """ 353 profiles_dir = os.path.join( 354 os.environ['HOME'], 'Library', 'MobileDevice', 'Provisioning Profiles') 355 if not os.path.isdir(profiles_dir): 356 print >>sys.stderr, ( 357 'cannot find mobile provisioning for %s' % bundle_identifier) 358 sys.exit(1) 359 provisioning_profiles = None 360 if profile: 361 profile_path = os.path.join(profiles_dir, profile + '.mobileprovision') 362 if os.path.exists(profile_path): 363 provisioning_profiles = [profile_path] 364 if not provisioning_profiles: 365 provisioning_profiles = glob.glob( 366 os.path.join(profiles_dir, '*.mobileprovision')) 367 valid_provisioning_profiles = {} 368 for profile_path in provisioning_profiles: 369 profile_data = self._LoadProvisioningProfile(profile_path) 370 app_id_pattern = profile_data.get( 371 'Entitlements', {}).get('application-identifier', '') 372 for team_identifier in profile_data.get('TeamIdentifier', []): 373 app_id = '%s.%s' % (team_identifier, bundle_identifier) 374 if fnmatch.fnmatch(app_id, app_id_pattern): 375 valid_provisioning_profiles[app_id_pattern] = ( 376 profile_path, profile_data, team_identifier) 377 if not valid_provisioning_profiles: 378 print >>sys.stderr, ( 379 'cannot find mobile provisioning for %s' % bundle_identifier) 380 sys.exit(1) 381 # If the user has multiple provisioning profiles installed that can be 382 # used for ${bundle_identifier}, pick the most specific one (ie. the 383 # provisioning profile whose pattern is the longest). 384 selected_key = max(valid_provisioning_profiles, key=lambda v: len(v)) 385 return valid_provisioning_profiles[selected_key] 386 387 def _LoadProvisioningProfile(self, profile_path): 388 """Extracts the plist embedded in a provisioning profile. 389 390 Args: 391 profile_path: string, path to the .mobileprovision file 392 393 Returns: 394 Content of the plist embedded in the provisioning profile as a dictionary. 395 """ 396 with tempfile.NamedTemporaryFile() as temp: 397 subprocess.check_call([ 398 'security', 'cms', '-D', '-i', profile_path, '-o', temp.name]) 399 return self._LoadPlistMaybeBinary(temp.name) 400 401 def _LoadPlistMaybeBinary(self, plist_path): 402 """Loads into a memory a plist possibly encoded in binary format. 403 404 This is a wrapper around plistlib.readPlist that tries to convert the 405 plist to the XML format if it can't be parsed (assuming that it is in 406 the binary format). 407 408 Args: 409 plist_path: string, path to a plist file, in XML or binary format 410 411 Returns: 412 Content of the plist as a dictionary. 413 """ 414 try: 415 # First, try to read the file using plistlib that only supports XML, 416 # and if an exception is raised, convert a temporary copy to XML and 417 # load that copy. 418 return plistlib.readPlist(plist_path) 419 except: 420 pass 421 with tempfile.NamedTemporaryFile() as temp: 422 shutil.copy2(plist_path, temp.name) 423 subprocess.check_call(['plutil', '-convert', 'xml1', temp.name]) 424 return plistlib.readPlist(temp.name) 425 426 def _GetSubstitutions(self, bundle_identifier, app_identifier_prefix): 427 """Constructs a dictionary of variable substitutions for Entitlements.plist. 428 429 Args: 430 bundle_identifier: string, value of CFBundleIdentifier from Info.plist 431 app_identifier_prefix: string, value for AppIdentifierPrefix 432 433 Returns: 434 Dictionary of substitutions to apply when generating Entitlements.plist. 435 """ 436 return { 437 'CFBundleIdentifier': bundle_identifier, 438 'AppIdentifierPrefix': app_identifier_prefix, 439 } 440 441 def _GetCFBundleIdentifier(self): 442 """Extracts CFBundleIdentifier value from Info.plist in the bundle. 443 444 Returns: 445 Value of CFBundleIdentifier in the Info.plist located in the bundle. 446 """ 447 info_plist_path = os.path.join( 448 os.environ['TARGET_BUILD_DIR'], 449 os.environ['INFOPLIST_PATH']) 450 info_plist_data = self._LoadPlistMaybeBinary(info_plist_path) 451 return info_plist_data['CFBundleIdentifier'] 452 453 def _InstallEntitlements(self, entitlements, substitutions, overrides): 454 """Generates and install the ${BundleName}.xcent entitlements file. 455 456 Expands variables "$(variable)" pattern in the source entitlements file, 457 add extra entitlements defined in the .mobileprovision file and the copy 458 the generated plist to "${BundlePath}.xcent". 459 460 Args: 461 entitlements: string, optional, path to the Entitlements.plist template 462 to use, defaults to "${SDKROOT}/Entitlements.plist" 463 substitutions: dictionary, variable substitutions 464 overrides: dictionary, values to add to the entitlements 465 466 Returns: 467 Path to the generated entitlements file. 468 """ 469 source_path = entitlements 470 target_path = os.path.join( 471 os.environ['BUILT_PRODUCTS_DIR'], 472 os.environ['PRODUCT_NAME'] + '.xcent') 473 if not source_path: 474 source_path = os.path.join( 475 os.environ['SDKROOT'], 476 'Entitlements.plist') 477 shutil.copy2(source_path, target_path) 478 data = self._LoadPlistMaybeBinary(target_path) 479 data = self._ExpandVariables(data, substitutions) 480 if overrides: 481 for key in overrides: 482 if key not in data: 483 data[key] = overrides[key] 484 plistlib.writePlist(data, target_path) 485 return target_path 486 487 def _ExpandVariables(self, data, substitutions): 488 """Expands variables "$(variable)" in data. 489 490 Args: 491 data: object, can be either string, list or dictionary 492 substitutions: dictionary, variable substitutions to perform 493 494 Returns: 495 Copy of data where each references to "$(variable)" has been replaced 496 by the corresponding value found in substitutions, or left intact if 497 the key was not found. 498 """ 499 if isinstance(data, str): 500 for key, value in substitutions.iteritems(): 501 data = data.replace('$(%s)' % key, value) 502 return data 503 if isinstance(data, list): 504 return [self._ExpandVariables(v, substitutions) for v in data] 505 if isinstance(data, dict): 506 return {k: self._ExpandVariables(data[k], substitutions) for k in data} 507 return data 508 509 if __name__ == '__main__': 510 sys.exit(main(sys.argv[1:])) 511