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):
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)
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 _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"
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)
111 fp = open(dest, 'wb')
112 fp.write(s.decode(input_code).encode('UTF-16'))
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')
125 if header.startswith("\xFE\xFF"):
127 elif header.startswith("\xFF\xFE"):
129 elif header.startswith("\xEF\xBB\xBF"):
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')
141 # Insert synthesized key/value pairs (e.g. BuildMachineOSBuild).
142 plist = plistlib.readPlistFromString(lines)
144 plist = dict(plist.items() + json.loads(keys[0]).items())
145 lines = plistlib.writePlistToString(plist)
147 # Go through all the environment variables and replace them as variables in
149 IDENT_RE = re.compile('[/\s]')
150 for key in os.environ:
151 if key.startswith('_'):
154 evalue = os.environ[key]
155 lines = string.replace(lines, evar, evalue)
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)
167 evar = '${%s:rfc1034identifier}' % key
168 evalue = IDENT_RE.sub('-', os.environ[key])
169 lines = string.replace(lines, evar, evalue)
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>${"):
177 lines = '\n'.join(filter(lambda x: x is not None, lines))
179 # Write out the file with variables replaced.
184 # Now write out PkgInfo file now that the Info.plist file has been
186 self._WritePkgInfo(dest)
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)
194 # Only create PkgInfo for executable types.
195 package_type = plist['CFBundlePackageType']
196 if package_type != 'APPL':
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
206 dest = os.path.join(os.path.dirname(info_plist), 'PkgInfo')
208 fp.write('%s%s' % (package_type, signature_code))
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)
218 def ExecFilterLibtool(self, *cmd_list):
219 """Calls libtool and filters out '/path/to/libtool: file: foo.o has no
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
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]
236 RESOURCES = 'Resources'
237 VERSIONS = 'Versions'
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).
244 # Move into the framework directory to set the symlinks correctly.
248 # Set up the Current version.
249 self._Relink(version, os.path.join(VERSIONS, CURRENT))
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)
255 # Back to where we were before!
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):
263 os.symlink(dest, link)
265 def ExecCodeSignBundle(self, key, resource_rules, entitlements, provisioning):
266 """Code sign a bundle.
268 This function tries to code sign an iOS bundle, following the same
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.
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,
285 os.environ['TARGET_BUILD_DIR'],
286 os.environ['FULL_PRODUCT_NAME'])])
288 def _InstallResourceRules(self, resource_rules):
289 """Installs ResourceRules.plist from user or SDK into the bundle.
292 resource_rules: string, optional, path to the ResourceRules.plist file
293 to use, default to "${SDKROOT}/ResourceRules.plist"
296 Path to the copy of ResourceRules.plist into the bundle.
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')
304 source_path = os.path.join(
305 os.environ['SDKROOT'], 'ResourceRules.plist')
306 shutil.copy2(source_path, target_path)
309 def _InstallProvisioningProfile(self, profile, bundle_identifier):
310 """Installs embedded.mobileprovision into the bundle.
313 profile: string, optional, short name of the .mobileprovision file
314 to use, if empty or the file is missing, the best file installed
316 bundle_identifier: string, value of CFBundleIdentifier from Info.plist
319 A tuple containing two dictionary: variables substitutions and values
320 to overrides when generating the entitlements file.
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']
332 def _FindProvisioningProfile(self, profile, bundle_identifier):
333 """Finds the .mobileprovision file to use for signing the bundle.
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.
340 profile: string, optional, short name of the .mobileprovision file
341 to use, if empty or the file is missing, the best file installed
343 bundle_identifier: string, value of CFBundleIdentifier from Info.plist
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.
351 SystemExit: if no .mobileprovision can be used to sign the bundle.
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)
359 provisioning_profiles = None
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)
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]
387 def _LoadProvisioningProfile(self, profile_path):
388 """Extracts the plist embedded in a provisioning profile.
391 profile_path: string, path to the .mobileprovision file
394 Content of the plist embedded in the provisioning profile as a dictionary.
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)
401 def _LoadPlistMaybeBinary(self, plist_path):
402 """Loads into a memory a plist possibly encoded in binary format.
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
409 plist_path: string, path to a plist file, in XML or binary format
412 Content of the plist as a dictionary.
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
418 return plistlib.readPlist(plist_path)
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)
426 def _GetSubstitutions(self, bundle_identifier, app_identifier_prefix):
427 """Constructs a dictionary of variable substitutions for Entitlements.plist.
430 bundle_identifier: string, value of CFBundleIdentifier from Info.plist
431 app_identifier_prefix: string, value for AppIdentifierPrefix
434 Dictionary of substitutions to apply when generating Entitlements.plist.
437 'CFBundleIdentifier': bundle_identifier,
438 'AppIdentifierPrefix': app_identifier_prefix,
441 def _GetCFBundleIdentifier(self):
442 """Extracts CFBundleIdentifier value from Info.plist in the bundle.
445 Value of CFBundleIdentifier in the Info.plist located in the bundle.
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']
453 def _InstallEntitlements(self, entitlements, substitutions, overrides):
454 """Generates and install the ${BundleName}.xcent entitlements file.
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".
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
467 Path to the generated entitlements file.
469 source_path = entitlements
470 target_path = os.path.join(
471 os.environ['BUILT_PRODUCTS_DIR'],
472 os.environ['PRODUCT_NAME'] + '.xcent')
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)
481 for key in overrides:
483 data[key] = overrides[key]
484 plistlib.writePlist(data, target_path)
487 def _ExpandVariables(self, data, substitutions):
488 """Expands variables "$(variable)" in data.
491 data: object, can be either string, list or dictionary
492 substitutions: dictionary, variable substitutions to perform
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.
499 if isinstance(data, str):
500 for key, value in substitutions.iteritems():
501 data = data.replace('$(%s)' % key, value)
503 if isinstance(data, list):
504 return [self._ExpandVariables(v, substitutions) for v in data]
505 if isinstance(data, dict):
506 return dict((k, self._ExpandVariables(data[k],
507 substitutions)) for k in data)
510 if __name__ == '__main__':
511 sys.exit(main(sys.argv[1:]))