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.
6 """Utility functions to perform Xcode-style build steps.
8 These functions are executed via gyp-mac-tool when using the Makefile generator.
27 exit_code = executor.Dispatch(args)
28 if exit_code is not None:
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."""
36 def Dispatch(self, args):
37 """Dispatches a string command to a method."""
39 raise Exception("Not enough arguments")
41 method = "Exec%s" % self._CommandifyName(args[0])
42 return getattr(self, method)(*args[1:])
44 def _CommandifyName(self, name_string):
45 """Transforms a tool name like copy-info-plist to CopyInfoPlist"""
46 return name_string.title().replace('-', '')
48 def ExecCopyBundleResource(self, source, dest, convert_to_binary):
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):
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):
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, convert_to_binary)
67 shutil.copy(source, dest)
69 def _CopyXIBFile(self, source, dest):
70 """Compiles a XIB file with ibtool into a binary plist in the bundle."""
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)
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
95 def _ConvertToBinary(self, dest):
96 subprocess.check_call([
97 'xcrun', 'plutil', '-convert', 'binary1', '-o', dest, dest])
99 def _CopyStringsFile(self, source, dest, convert_to_binary):
100 """Copies a .strings file using iconv to reconvert the input into UTF-16."""
101 input_code = self._DetectInputEncoding(source) or "UTF-8"
103 # Xcode's CpyCopyStringsFile / builtin-copyStrings seems to call
104 # CFPropertyListCreateFromXMLData() behind the scenes; at least it prints
105 # CFPropertyListCreateFromXMLData(): Old-style plist parser: missing
106 # semicolon in dictionary.
107 # on invalid files. Do the same kind of validation.
108 import CoreFoundation
109 s = open(source, 'rb').read()
110 d = CoreFoundation.CFDataCreate(None, s, len(s))
111 _, error = CoreFoundation.CFPropertyListCreateFromXMLData(None, d, 0, None)
115 fp = open(dest, 'wb')
116 fp.write(s.decode(input_code).encode('UTF-16'))
119 if convert_to_binary == 'True':
120 self._ConvertToBinary(dest)
122 def _DetectInputEncoding(self, file_name):
123 """Reads the first few bytes from file_name and tries to guess the text
124 encoding. Returns None as a guess if it can't detect it."""
125 fp = open(file_name, 'rb')
132 if header.startswith("\xFE\xFF"):
134 elif header.startswith("\xFF\xFE"):
136 elif header.startswith("\xEF\xBB\xBF"):
141 def ExecCopyInfoPlist(self, source, dest, convert_to_binary, *keys):
142 """Copies the |source| Info.plist to the destination directory |dest|."""
143 # Read the source Info.plist into memory.
144 fd = open(source, 'r')
148 # Insert synthesized key/value pairs (e.g. BuildMachineOSBuild).
149 plist = plistlib.readPlistFromString(lines)
151 plist = dict(plist.items() + json.loads(keys[0]).items())
152 lines = plistlib.writePlistToString(plist)
154 # Go through all the environment variables and replace them as variables in
156 IDENT_RE = re.compile(r'[/\s]')
157 for key in os.environ:
158 if key.startswith('_'):
161 evalue = os.environ[key]
162 lines = string.replace(lines, evar, evalue)
164 # Xcode supports various suffices on environment variables, which are
165 # all undocumented. :rfc1034identifier is used in the standard project
166 # template these days, and :identifier was used earlier. They are used to
167 # convert non-url characters into things that look like valid urls --
168 # except that the replacement character for :identifier, '_' isn't valid
169 # in a URL either -- oops, hence :rfc1034identifier was born.
170 evar = '${%s:identifier}' % key
171 evalue = IDENT_RE.sub('_', os.environ[key])
172 lines = string.replace(lines, evar, evalue)
174 evar = '${%s:rfc1034identifier}' % key
175 evalue = IDENT_RE.sub('-', os.environ[key])
176 lines = string.replace(lines, evar, evalue)
178 # Remove any keys with values that haven't been replaced.
179 lines = lines.split('\n')
180 for i in range(len(lines)):
181 if lines[i].strip().startswith("<string>${"):
184 lines = '\n'.join(filter(lambda x: x is not None, lines))
186 # Write out the file with variables replaced.
191 # Now write out PkgInfo file now that the Info.plist file has been
193 self._WritePkgInfo(dest)
195 if convert_to_binary == 'True':
196 self._ConvertToBinary(dest)
198 def _WritePkgInfo(self, info_plist):
199 """This writes the PkgInfo file from the data stored in Info.plist."""
200 plist = plistlib.readPlist(info_plist)
204 # Only create PkgInfo for executable types.
205 package_type = plist['CFBundlePackageType']
206 if package_type != 'APPL':
209 # The format of PkgInfo is eight characters, representing the bundle type
210 # and bundle signature, each four characters. If that is missing, four
211 # '?' characters are used instead.
212 signature_code = plist.get('CFBundleSignature', '????')
213 if len(signature_code) != 4: # Wrong length resets everything, too.
214 signature_code = '?' * 4
216 dest = os.path.join(os.path.dirname(info_plist), 'PkgInfo')
218 fp.write('%s%s' % (package_type, signature_code))
221 def ExecFlock(self, lockfile, *cmd_list):
222 """Emulates the most basic behavior of Linux's flock(1)."""
223 # Rely on exception handling to report errors.
224 fd = os.open(lockfile, os.O_RDONLY|os.O_NOCTTY|os.O_CREAT, 0o666)
225 fcntl.flock(fd, fcntl.LOCK_EX)
226 return subprocess.call(cmd_list)
228 def ExecFilterLibtool(self, *cmd_list):
229 """Calls libtool and filters out '/path/to/libtool: file: foo.o has no
231 libtool_re = re.compile(r'^.*libtool: file: .* has no symbols$')
232 libtool_re5 = re.compile(
233 r'^.*libtool: warning for library: ' +
234 r'.* the table of contents is empty ' +
235 r'\(no object file members in the library define global symbols\)$')
236 env = os.environ.copy()
238 # http://www.opensource.apple.com/source/cctools/cctools-809/misc/libtool.c
239 # The problem with this flag is that it resets the file mtime on the file to
240 # epoch=0, e.g. 1970-1-1 or 1969-12-31 depending on timezone.
241 env['ZERO_AR_DATE'] = '1'
242 libtoolout = subprocess.Popen(cmd_list, stderr=subprocess.PIPE, env=env)
243 _, err = libtoolout.communicate()
244 for line in err.splitlines():
245 if not libtool_re.match(line) and not libtool_re5.match(line):
246 print >>sys.stderr, line
247 # Unconditionally touch the output .a file on the command line if present
248 # and the command succeeded. A bit hacky.
249 if not libtoolout.returncode:
250 for i in range(len(cmd_list) - 1):
251 if cmd_list[i] == "-o" and cmd_list[i+1].endswith('.a'):
252 os.utime(cmd_list[i+1], None)
254 return libtoolout.returncode
256 def ExecPackageFramework(self, framework, version):
257 """Takes a path to Something.framework and the Current version of that and
258 sets up all the symlinks."""
259 # Find the name of the binary based on the part before the ".framework".
260 binary = os.path.basename(framework).split('.')[0]
263 RESOURCES = 'Resources'
264 VERSIONS = 'Versions'
266 if not os.path.exists(os.path.join(framework, VERSIONS, version, binary)):
267 # Binary-less frameworks don't seem to contain symlinks (see e.g.
268 # chromium's out/Debug/org.chromium.Chromium.manifest/ bundle).
271 # Move into the framework directory to set the symlinks correctly.
275 # Set up the Current version.
276 self._Relink(version, os.path.join(VERSIONS, CURRENT))
278 # Set up the root symlinks.
279 self._Relink(os.path.join(VERSIONS, CURRENT, binary), binary)
280 self._Relink(os.path.join(VERSIONS, CURRENT, RESOURCES), RESOURCES)
282 # Back to where we were before!
285 def _Relink(self, dest, link):
286 """Creates a symlink to |dest| named |link|. If |link| already exists,
287 it is overwritten."""
288 if os.path.lexists(link):
290 os.symlink(dest, link)
292 def ExecCompileXcassets(self, keys, *inputs):
293 """Compiles multiple .xcassets files into a single .car file.
295 This invokes 'actool' to compile all the inputs .xcassets files. The
296 |keys| arguments is a json-encoded dictionary of extra arguments to
297 pass to 'actool' when the asset catalogs contains an application icon
300 Note that 'actool' does not create the Assets.car file if the asset
301 catalogs does not contains imageset.
304 'xcrun', 'actool', '--output-format', 'human-readable-text',
305 '--compress-pngs', '--notices', '--warnings', '--errors',
307 is_iphone_target = 'IPHONEOS_DEPLOYMENT_TARGET' in os.environ
309 platform = os.environ['CONFIGURATION'].split('-')[-1]
310 if platform not in ('iphoneos', 'iphonesimulator'):
311 platform = 'iphonesimulator'
312 command_line.extend([
313 '--platform', platform, '--target-device', 'iphone',
314 '--target-device', 'ipad', '--minimum-deployment-target',
315 os.environ['IPHONEOS_DEPLOYMENT_TARGET'], '--compile',
316 os.path.abspath(os.environ['CONTENTS_FOLDER_PATH']),
319 command_line.extend([
320 '--platform', 'macosx', '--target-device', 'mac',
321 '--minimum-deployment-target', os.environ['MACOSX_DEPLOYMENT_TARGET'],
323 os.path.abspath(os.environ['UNLOCALIZED_RESOURCES_FOLDER_PATH']),
326 keys = json.loads(keys)
327 for key, value in keys.iteritems():
328 arg_name = '--' + key
329 if isinstance(value, bool):
331 command_line.append(arg_name)
332 elif isinstance(value, list):
334 command_line.append(arg_name)
335 command_line.append(str(v))
337 command_line.append(arg_name)
338 command_line.append(str(value))
339 # Note: actool crashes if inputs path are relative, so use os.path.abspath
340 # to get absolute path name for inputs.
341 command_line.extend(map(os.path.abspath, inputs))
342 subprocess.check_call(command_line)
344 def ExecMergeInfoPlist(self, output, *inputs):
345 """Merge multiple .plist files into a single .plist file."""
348 plist = self._LoadPlistMaybeBinary(path)
349 self._MergePlist(merged_plist, plist)
350 plistlib.writePlist(merged_plist, output)
352 def ExecCodeSignBundle(self, key, resource_rules, entitlements, provisioning):
353 """Code sign a bundle.
355 This function tries to code sign an iOS bundle, following the same
357 1. copy ResourceRules.plist from the user or the SDK into the bundle,
358 2. pick the provisioning profile that best match the bundle identifier,
359 and copy it into the bundle as embedded.mobileprovision,
360 3. copy Entitlements.plist from user or SDK next to the bundle,
361 4. code sign the bundle.
363 resource_rules_path = self._InstallResourceRules(resource_rules)
364 substitutions, overrides = self._InstallProvisioningProfile(
365 provisioning, self._GetCFBundleIdentifier())
366 entitlements_path = self._InstallEntitlements(
367 entitlements, substitutions, overrides)
368 subprocess.check_call([
369 'codesign', '--force', '--sign', key, '--resource-rules',
370 resource_rules_path, '--entitlements', entitlements_path,
372 os.environ['TARGET_BUILD_DIR'],
373 os.environ['FULL_PRODUCT_NAME'])])
375 def _InstallResourceRules(self, resource_rules):
376 """Installs ResourceRules.plist from user or SDK into the bundle.
379 resource_rules: string, optional, path to the ResourceRules.plist file
380 to use, default to "${SDKROOT}/ResourceRules.plist"
383 Path to the copy of ResourceRules.plist into the bundle.
385 source_path = resource_rules
386 target_path = os.path.join(
387 os.environ['BUILT_PRODUCTS_DIR'],
388 os.environ['CONTENTS_FOLDER_PATH'],
389 'ResourceRules.plist')
391 source_path = os.path.join(
392 os.environ['SDKROOT'], 'ResourceRules.plist')
393 shutil.copy2(source_path, target_path)
396 def _InstallProvisioningProfile(self, profile, bundle_identifier):
397 """Installs embedded.mobileprovision into the bundle.
400 profile: string, optional, short name of the .mobileprovision file
401 to use, if empty or the file is missing, the best file installed
403 bundle_identifier: string, value of CFBundleIdentifier from Info.plist
406 A tuple containing two dictionary: variables substitutions and values
407 to overrides when generating the entitlements file.
409 source_path, provisioning_data, team_id = self._FindProvisioningProfile(
410 profile, bundle_identifier)
411 target_path = os.path.join(
412 os.environ['BUILT_PRODUCTS_DIR'],
413 os.environ['CONTENTS_FOLDER_PATH'],
414 'embedded.mobileprovision')
415 shutil.copy2(source_path, target_path)
416 substitutions = self._GetSubstitutions(bundle_identifier, team_id + '.')
417 return substitutions, provisioning_data['Entitlements']
419 def _FindProvisioningProfile(self, profile, bundle_identifier):
420 """Finds the .mobileprovision file to use for signing the bundle.
422 Checks all the installed provisioning profiles (or if the user specified
423 the PROVISIONING_PROFILE variable, only consult it) and select the most
424 specific that correspond to the bundle identifier.
427 profile: string, optional, short name of the .mobileprovision file
428 to use, if empty or the file is missing, the best file installed
430 bundle_identifier: string, value of CFBundleIdentifier from Info.plist
433 A tuple of the path to the selected provisioning profile, the data of
434 the embedded plist in the provisioning profile and the team identifier
435 to use for code signing.
438 SystemExit: if no .mobileprovision can be used to sign the bundle.
440 profiles_dir = os.path.join(
441 os.environ['HOME'], 'Library', 'MobileDevice', 'Provisioning Profiles')
442 if not os.path.isdir(profiles_dir):
443 print >>sys.stderr, (
444 'cannot find mobile provisioning for %s' % bundle_identifier)
446 provisioning_profiles = None
448 profile_path = os.path.join(profiles_dir, profile + '.mobileprovision')
449 if os.path.exists(profile_path):
450 provisioning_profiles = [profile_path]
451 if not provisioning_profiles:
452 provisioning_profiles = glob.glob(
453 os.path.join(profiles_dir, '*.mobileprovision'))
454 valid_provisioning_profiles = {}
455 for profile_path in provisioning_profiles:
456 profile_data = self._LoadProvisioningProfile(profile_path)
457 app_id_pattern = profile_data.get(
458 'Entitlements', {}).get('application-identifier', '')
459 for team_identifier in profile_data.get('TeamIdentifier', []):
460 app_id = '%s.%s' % (team_identifier, bundle_identifier)
461 if fnmatch.fnmatch(app_id, app_id_pattern):
462 valid_provisioning_profiles[app_id_pattern] = (
463 profile_path, profile_data, team_identifier)
464 if not valid_provisioning_profiles:
465 print >>sys.stderr, (
466 'cannot find mobile provisioning for %s' % bundle_identifier)
468 # If the user has multiple provisioning profiles installed that can be
469 # used for ${bundle_identifier}, pick the most specific one (ie. the
470 # provisioning profile whose pattern is the longest).
471 selected_key = max(valid_provisioning_profiles, key=lambda v: len(v))
472 return valid_provisioning_profiles[selected_key]
474 def _LoadProvisioningProfile(self, profile_path):
475 """Extracts the plist embedded in a provisioning profile.
478 profile_path: string, path to the .mobileprovision file
481 Content of the plist embedded in the provisioning profile as a dictionary.
483 with tempfile.NamedTemporaryFile() as temp:
484 subprocess.check_call([
485 'security', 'cms', '-D', '-i', profile_path, '-o', temp.name])
486 return self._LoadPlistMaybeBinary(temp.name)
488 def _MergePlist(self, merged_plist, plist):
489 """Merge |plist| into |merged_plist|."""
490 for key, value in plist.iteritems():
491 if isinstance(value, dict):
492 merged_value = merged_plist.get(key, {})
493 if isinstance(merged_value, dict):
494 self._MergePlist(merged_value, value)
495 merged_plist[key] = merged_value
497 merged_plist[key] = value
499 merged_plist[key] = value
501 def _LoadPlistMaybeBinary(self, plist_path):
502 """Loads into a memory a plist possibly encoded in binary format.
504 This is a wrapper around plistlib.readPlist that tries to convert the
505 plist to the XML format if it can't be parsed (assuming that it is in
509 plist_path: string, path to a plist file, in XML or binary format
512 Content of the plist as a dictionary.
515 # First, try to read the file using plistlib that only supports XML,
516 # and if an exception is raised, convert a temporary copy to XML and
518 return plistlib.readPlist(plist_path)
521 with tempfile.NamedTemporaryFile() as temp:
522 shutil.copy2(plist_path, temp.name)
523 subprocess.check_call(['plutil', '-convert', 'xml1', temp.name])
524 return plistlib.readPlist(temp.name)
526 def _GetSubstitutions(self, bundle_identifier, app_identifier_prefix):
527 """Constructs a dictionary of variable substitutions for Entitlements.plist.
530 bundle_identifier: string, value of CFBundleIdentifier from Info.plist
531 app_identifier_prefix: string, value for AppIdentifierPrefix
534 Dictionary of substitutions to apply when generating Entitlements.plist.
537 'CFBundleIdentifier': bundle_identifier,
538 'AppIdentifierPrefix': app_identifier_prefix,
541 def _GetCFBundleIdentifier(self):
542 """Extracts CFBundleIdentifier value from Info.plist in the bundle.
545 Value of CFBundleIdentifier in the Info.plist located in the bundle.
547 info_plist_path = os.path.join(
548 os.environ['TARGET_BUILD_DIR'],
549 os.environ['INFOPLIST_PATH'])
550 info_plist_data = self._LoadPlistMaybeBinary(info_plist_path)
551 return info_plist_data['CFBundleIdentifier']
553 def _InstallEntitlements(self, entitlements, substitutions, overrides):
554 """Generates and install the ${BundleName}.xcent entitlements file.
556 Expands variables "$(variable)" pattern in the source entitlements file,
557 add extra entitlements defined in the .mobileprovision file and the copy
558 the generated plist to "${BundlePath}.xcent".
561 entitlements: string, optional, path to the Entitlements.plist template
562 to use, defaults to "${SDKROOT}/Entitlements.plist"
563 substitutions: dictionary, variable substitutions
564 overrides: dictionary, values to add to the entitlements
567 Path to the generated entitlements file.
569 source_path = entitlements
570 target_path = os.path.join(
571 os.environ['BUILT_PRODUCTS_DIR'],
572 os.environ['PRODUCT_NAME'] + '.xcent')
574 source_path = os.path.join(
575 os.environ['SDKROOT'],
576 'Entitlements.plist')
577 shutil.copy2(source_path, target_path)
578 data = self._LoadPlistMaybeBinary(target_path)
579 data = self._ExpandVariables(data, substitutions)
581 for key in overrides:
583 data[key] = overrides[key]
584 plistlib.writePlist(data, target_path)
587 def _ExpandVariables(self, data, substitutions):
588 """Expands variables "$(variable)" in data.
591 data: object, can be either string, list or dictionary
592 substitutions: dictionary, variable substitutions to perform
595 Copy of data where each references to "$(variable)" has been replaced
596 by the corresponding value found in substitutions, or left intact if
597 the key was not found.
599 if isinstance(data, str):
600 for key, value in substitutions.iteritems():
601 data = data.replace('$(%s)' % key, value)
603 if isinstance(data, list):
604 return [self._ExpandVariables(v, substitutions) for v in data]
605 if isinstance(data, dict):
606 return {k: self._ExpandVariables(data[k], substitutions) for k in data}
609 if __name__ == '__main__':
610 sys.exit(main(sys.argv[1:]))