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 os
13import plistlib
14import re
15import shutil
16import string
17import subprocess
18import sys
19
20
21def main(args):
22  executor = MacTool()
23  exit_code = executor.Dispatch(args)
24  if exit_code is not None:
25    sys.exit(exit_code)
26
27
28class MacTool(object):
29  """This class performs all the Mac tooling steps. The methods can either be
30  executed directly, or dispatched from an argument list."""
31
32  def Dispatch(self, args):
33    """Dispatches a string command to a method."""
34    if len(args) < 1:
35      raise Exception("Not enough arguments")
36
37    method = "Exec%s" % self._CommandifyName(args[0])
38    return getattr(self, method)(*args[1:])
39
40  def _CommandifyName(self, name_string):
41    """Transforms a tool name like copy-info-plist to CopyInfoPlist"""
42    return name_string.title().replace('-', '')
43
44  def ExecCopyBundleResource(self, source, dest):
45    """Copies a resource file to the bundle/Resources directory, performing any
46    necessary compilation on each resource."""
47    extension = os.path.splitext(source)[1].lower()
48    if os.path.isdir(source):
49      # Copy tree.
50      if os.path.exists(dest):
51        shutil.rmtree(dest)
52      shutil.copytree(source, dest)
53    elif extension == '.xib':
54      return self._CopyXIBFile(source, dest)
55    elif extension == '.strings':
56      self._CopyStringsFile(source, dest)
57    else:
58      shutil.copyfile(source, dest)
59
60  def _CopyXIBFile(self, source, dest):
61    """Compiles a XIB file with ibtool into a binary plist in the bundle."""
62    tools_dir = os.environ.get('DEVELOPER_BIN_DIR', '/usr/bin')
63    args = [os.path.join(tools_dir, 'ibtool'), '--errors', '--warnings',
64        '--notices', '--output-format', 'human-readable-text', '--compile',
65        dest, source]
66    ibtool_section_re = re.compile(r'/\*.*\*/')
67    ibtool_re = re.compile(r'.*note:.*is clipping its content')
68    ibtoolout = subprocess.Popen(args, stdout=subprocess.PIPE)
69    current_section_header = None
70    for line in ibtoolout.stdout:
71      if ibtool_section_re.match(line):
72        current_section_header = line
73      elif not ibtool_re.match(line):
74        if current_section_header:
75          sys.stdout.write(current_section_header)
76          current_section_header = None
77        sys.stdout.write(line)
78    return ibtoolout.returncode
79
80  def _CopyStringsFile(self, source, dest):
81    """Copies a .strings file using iconv to reconvert the input into UTF-16."""
82    input_code = self._DetectInputEncoding(source) or "UTF-8"
83
84    # Xcode's CpyCopyStringsFile / builtin-copyStrings seems to call
85    # CFPropertyListCreateFromXMLData() behind the scenes; at least it prints
86    #     CFPropertyListCreateFromXMLData(): Old-style plist parser: missing
87    #     semicolon in dictionary.
88    # on invalid files. Do the same kind of validation.
89    import CoreFoundation
90    s = open(source).read()
91    d = CoreFoundation.CFDataCreate(None, s, len(s))
92    _, error = CoreFoundation.CFPropertyListCreateFromXMLData(None, d, 0, None)
93    if error:
94      return
95
96    fp = open(dest, 'w')
97    args = ['/usr/bin/iconv', '--from-code', input_code, '--to-code',
98        'UTF-16', source]
99    subprocess.call(args, stdout=fp)
100    fp.close()
101
102  def _DetectInputEncoding(self, file_name):
103    """Reads the first few bytes from file_name and tries to guess the text
104    encoding. Returns None as a guess if it can't detect it."""
105    fp = open(file_name, 'rb')
106    try:
107      header = fp.read(3)
108    except e:
109      fp.close()
110      return None
111    fp.close()
112    if header.startswith("\xFE\xFF"):
113      return "UTF-16BE"
114    elif header.startswith("\xFF\xFE"):
115      return "UTF-16LE"
116    elif header.startswith("\xEF\xBB\xBF"):
117      return "UTF-8"
118    else:
119      return None
120
121  def ExecCopyInfoPlist(self, source, dest):
122    """Copies the |source| Info.plist to the destination directory |dest|."""
123    # Read the source Info.plist into memory.
124    fd = open(source, 'r')
125    lines = fd.read()
126    fd.close()
127
128    # Go through all the environment variables and replace them as variables in
129    # the file.
130    for key in os.environ:
131      if key.startswith('_'):
132        continue
133      evar = '${%s}' % key
134      lines = string.replace(lines, evar, os.environ[key])
135
136    # Remove any keys with values that haven't been replaced.
137    lines = lines.split('\n')
138    for i in range(len(lines)):
139      if lines[i].strip().startswith("<string>${"):
140        lines[i] = None
141        lines[i - 1] = None
142    lines = '\n'.join(filter(lambda x: x is not None, lines))
143
144    # Write out the file with variables replaced.
145    fd = open(dest, 'w')
146    fd.write(lines)
147    fd.close()
148
149    # Now write out PkgInfo file now that the Info.plist file has been
150    # "compiled".
151    self._WritePkgInfo(dest)
152
153  def _WritePkgInfo(self, info_plist):
154    """This writes the PkgInfo file from the data stored in Info.plist."""
155    plist = plistlib.readPlist(info_plist)
156    if not plist:
157      return
158
159    # Only create PkgInfo for executable types.
160    package_type = plist['CFBundlePackageType']
161    if package_type != 'APPL':
162      return
163
164    # The format of PkgInfo is eight characters, representing the bundle type
165    # and bundle signature, each four characters. If that is missing, four
166    # '?' characters are used instead.
167    signature_code = plist.get('CFBundleSignature', '????')
168    if len(signature_code) != 4:  # Wrong length resets everything, too.
169      signature_code = '?' * 4
170
171    dest = os.path.join(os.path.dirname(info_plist), 'PkgInfo')
172    fp = open(dest, 'w')
173    fp.write('%s%s' % (package_type, signature_code))
174    fp.close()
175
176  def ExecFlock(self, lockfile, *cmd_list):
177    """Emulates the most basic behavior of Linux's flock(1)."""
178    # Rely on exception handling to report errors.
179    fd = os.open(lockfile, os.O_RDONLY|os.O_NOCTTY|os.O_CREAT, 0o666)
180    fcntl.flock(fd, fcntl.LOCK_EX)
181    return subprocess.call(cmd_list)
182
183  def ExecFilterLibtool(self, *cmd_list):
184    """Calls libtool and filters out 'libtool: file: foo.o has no symbols'."""
185    libtool_re = re.compile(r'^libtool: file: .* has no symbols$')
186    libtoolout = subprocess.Popen(cmd_list, stderr=subprocess.PIPE)
187    _, err = libtoolout.communicate()
188    for line in err.splitlines():
189      if not libtool_re.match(line):
190        print >>sys.stderr, line
191    return libtoolout.returncode
192
193  def ExecPackageFramework(self, framework, version):
194    """Takes a path to Something.framework and the Current version of that and
195    sets up all the symlinks."""
196    # Find the name of the binary based on the part before the ".framework".
197    binary = os.path.basename(framework).split('.')[0]
198
199    CURRENT = 'Current'
200    RESOURCES = 'Resources'
201    VERSIONS = 'Versions'
202
203    if not os.path.exists(os.path.join(framework, VERSIONS, version, binary)):
204      # Binary-less frameworks don't seem to contain symlinks (see e.g.
205      # chromium's out/Debug/org.chromium.Chromium.manifest/ bundle).
206      return
207
208    # Move into the framework directory to set the symlinks correctly.
209    pwd = os.getcwd()
210    os.chdir(framework)
211
212    # Set up the Current version.
213    self._Relink(version, os.path.join(VERSIONS, CURRENT))
214
215    # Set up the root symlinks.
216    self._Relink(os.path.join(VERSIONS, CURRENT, binary), binary)
217    self._Relink(os.path.join(VERSIONS, CURRENT, RESOURCES), RESOURCES)
218
219    # Back to where we were before!
220    os.chdir(pwd)
221
222  def _Relink(self, dest, link):
223    """Creates a symlink to |dest| named |link|. If |link| already exists,
224    it is overwritten."""
225    if os.path.lexists(link):
226      os.remove(link)
227    os.symlink(dest, link)
228
229
230if __name__ == '__main__':
231  sys.exit(main(sys.argv[1:]))
232