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