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