• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2016 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Wrapper around actool to compile assets catalog.
6
7The script compile_xcassets.py is a wrapper around actool to compile
8assets catalog to Assets.car that turns warning into errors. It also
9fixes some quirks of actool to make it work from ninja (mostly that
10actool seems to require absolute path but gn generates command-line
11with relative paths).
12
13The wrapper filter out any message that is not a section header and
14not a warning or error message, and fails if filtered output is not
15empty. This should to treat all warnings as error until actool has
16an option to fail with non-zero error code when there are warnings.
17"""
18
19import argparse
20import os
21import re
22import shutil
23import subprocess
24import sys
25import tempfile
26import zipfile
27
28# Pattern matching a section header in the output of actool.
29SECTION_HEADER = re.compile('^/\\* ([^ ]*) \\*/$')
30
31# Name of the section containing informational messages that can be ignored.
32NOTICE_SECTION = 'com.apple.actool.compilation-results'
33
34# App icon asset type.
35APP_ICON_ASSET_TYPE = '.appiconset'
36
37# Map special type of asset catalog to the corresponding command-line
38# parameter that need to be passed to actool.
39ACTOOL_FLAG_FOR_ASSET_TYPE = {
40    '.launchimage': '--launch-image',
41}
42
43def FixAbsolutePathInLine(line, relative_paths):
44  """Fix absolute paths present in |line| to relative paths."""
45  absolute_path = line.split(':')[0]
46  relative_path = relative_paths.get(absolute_path, absolute_path)
47  if absolute_path == relative_path:
48    return line
49  return relative_path + line[len(absolute_path):]
50
51
52def FilterCompilerOutput(compiler_output, relative_paths):
53  """Filers actool compilation output.
54
55  The compiler output is composed of multiple sections for each different
56  level of output (error, warning, notices, ...). Each section starts with
57  the section name on a single line, followed by all the messages from the
58  section.
59
60  The function filter any lines that are not in com.apple.actool.errors or
61  com.apple.actool.document.warnings sections (as spurious messages comes
62  before any section of the output).
63
64  See crbug.com/730054, crbug.com/739163 and crbug.com/770634 for some example
65  messages that pollute the output of actool and cause flaky builds.
66
67  Args:
68    compiler_output: string containing the output generated by the
69      compiler (contains both stdout and stderr)
70    relative_paths: mapping from absolute to relative paths used to
71      convert paths in the warning and error messages (unknown paths
72      will be left unaltered)
73
74  Returns:
75    The filtered output of the compiler. If the compilation was a
76    success, then the output will be empty, otherwise it will use
77    relative path and omit any irrelevant output.
78  """
79
80  filtered_output = []
81  current_section = None
82  data_in_section = False
83  for line in compiler_output.splitlines():
84    # TODO:(crbug.com/348008793): Ignore Dark and Tintable App Icon unassigned
85    # children warning when building with Xcode 15
86    if 'The app icon set "AppIcon" has 2 unassigned children' in line:
87      continue
88
89    match = SECTION_HEADER.search(line)
90    if match is not None:
91      data_in_section = False
92      current_section = match.group(1)
93      continue
94    if current_section and current_section != NOTICE_SECTION:
95      if not data_in_section:
96        data_in_section = True
97        filtered_output.append('/* %s */\n' % current_section)
98
99      fixed_line = FixAbsolutePathInLine(line, relative_paths)
100      filtered_output.append(fixed_line + '\n')
101
102  return ''.join(filtered_output)
103
104
105def CompileAssetCatalog(output, platform, target_environment, product_type,
106                        min_deployment_target, possibly_zipped_inputs,
107                        compress_pngs, partial_info_plist, app_icon,
108                        include_all_app_icons, temporary_dir):
109  """Compile the .xcassets bundles to an asset catalog using actool.
110
111  Args:
112    output: absolute path to the containing bundle
113    platform: the targeted platform
114    product_type: the bundle type
115    min_deployment_target: minimum deployment target
116    possibly_zipped_inputs: list of absolute paths to .xcassets bundles or zips
117    compress_pngs: whether to enable compression of pngs
118    partial_info_plist: path to partial Info.plist to generate
119    temporary_dir: path to directory for storing temp data
120  """
121  command = [
122      'xcrun',
123      'actool',
124      '--output-format=human-readable-text',
125      '--notices',
126      '--warnings',
127      '--errors',
128      '--minimum-deployment-target',
129      min_deployment_target,
130  ]
131
132  if compress_pngs:
133    command.extend(['--compress-pngs'])
134
135  if product_type != '':
136    command.extend(['--product-type', product_type])
137
138  if platform == 'mac':
139    command.extend([
140        '--platform',
141        'macosx',
142        '--target-device',
143        'mac',
144    ])
145  elif platform == 'ios':
146    if target_environment == 'simulator':
147      command.extend([
148          '--platform',
149          'iphonesimulator',
150          '--target-device',
151          'iphone',
152          '--target-device',
153          'ipad',
154      ])
155    elif target_environment == 'device':
156      command.extend([
157          '--platform',
158          'iphoneos',
159          '--target-device',
160          'iphone',
161          '--target-device',
162          'ipad',
163      ])
164    elif target_environment == 'catalyst':
165      command.extend([
166          '--platform',
167          'macosx',
168          '--target-device',
169          'ipad',
170          '--ui-framework-family',
171          'uikit',
172      ])
173    else:
174      sys.stderr.write('Unsupported ios environment: %s' % target_environment)
175      sys.exit(1)
176  elif platform == 'watchos':
177    if target_environment == 'simulator':
178      command.extend([
179          '--platform',
180          'watchsimulator',
181          '--target-device',
182          'watch',
183      ])
184    elif target_environment == 'device':
185      command.extend([
186          '--platform',
187          'watchos',
188          '--target-device',
189          'watch',
190      ])
191    else:
192      sys.stderr.write(
193        'Unsupported watchos environment: %s' % target_environment)
194      sys.exit(1)
195
196  # Unzip any input zipfiles to a temporary directory.
197  inputs = []
198  for relative_path in possibly_zipped_inputs:
199    if os.path.isfile(relative_path) and zipfile.is_zipfile(relative_path):
200      catalog_name = os.path.basename(relative_path)
201      unzip_path = os.path.join(temporary_dir, os.path.dirname(relative_path))
202      with zipfile.ZipFile(relative_path) as z:
203        invalid_files = [
204            x for x in z.namelist()
205            if '..' in x or not x.startswith(catalog_name)
206        ]
207        if invalid_files:
208          sys.stderr.write('Invalid files in zip: %s' % invalid_files)
209          sys.exit(1)
210        z.extractall(unzip_path)
211      inputs.append(os.path.join(unzip_path, catalog_name))
212    else:
213      inputs.append(relative_path)
214
215  # Scan the input directories for the presence of asset catalog types that
216  # require special treatment, and if so, add them to the actool command-line.
217  for relative_path in inputs:
218
219    if not os.path.isdir(relative_path):
220      continue
221
222    for file_or_dir_name in os.listdir(relative_path):
223      if not os.path.isdir(os.path.join(relative_path, file_or_dir_name)):
224        continue
225
226      asset_name, asset_type = os.path.splitext(file_or_dir_name)
227
228      # If the asset is an app icon, and the caller has specified an app icon
229      # to use, then skip this asset as it will be included in the app icon
230      # set. Otherwise, add the asset to the command-line.
231      if asset_type == APP_ICON_ASSET_TYPE:
232        if app_icon:
233          continue
234        else:
235          command.extend(['--app-icon', asset_name])
236
237      if asset_type not in ACTOOL_FLAG_FOR_ASSET_TYPE:
238        continue
239
240      command.extend([ACTOOL_FLAG_FOR_ASSET_TYPE[asset_type], asset_name])
241
242  if app_icon:
243    command.extend(['--app-icon', app_icon])
244
245  if include_all_app_icons:
246    command.extend(['--include-all-app-icons'])
247
248  # Always ask actool to generate a partial Info.plist file. If no path
249  # has been given by the caller, use a temporary file name.
250  temporary_file = None
251  if not partial_info_plist:
252    temporary_file = tempfile.NamedTemporaryFile(suffix='.plist')
253    partial_info_plist = temporary_file.name
254
255  command.extend(['--output-partial-info-plist', partial_info_plist])
256
257  # Dictionary used to convert absolute paths back to their relative form
258  # in the output of actool.
259  relative_paths = {}
260
261  # actool crashes if paths are relative, so convert input and output paths
262  # to absolute paths, and record the relative paths to fix them back when
263  # filtering the output.
264  absolute_output = os.path.abspath(output)
265  relative_paths[output] = absolute_output
266  relative_paths[os.path.dirname(output)] = os.path.dirname(absolute_output)
267  command.extend(['--compile', os.path.dirname(os.path.abspath(output))])
268
269  for relative_path in inputs:
270    absolute_path = os.path.abspath(relative_path)
271    relative_paths[absolute_path] = relative_path
272    command.append(absolute_path)
273
274  try:
275    # Run actool and redirect stdout and stderr to the same pipe (as actool
276    # is confused about what should go to stderr/stdout).
277    process = subprocess.Popen(command,
278                               stdout=subprocess.PIPE,
279                               stderr=subprocess.STDOUT)
280    stdout = process.communicate()[0].decode('utf-8')
281
282    # If the invocation of `actool` failed, copy all the compiler output to
283    # the standard error stream and exit. See https://crbug.com/1205775 for
284    # example of compilation that failed with no error message due to filter.
285    if process.returncode:
286      for line in stdout.splitlines():
287        fixed_line = FixAbsolutePathInLine(line, relative_paths)
288        sys.stderr.write(fixed_line + '\n')
289      sys.exit(1)
290
291    # Filter the output to remove all garbage and to fix the paths. If the
292    # output is not empty after filtering, then report the compilation as a
293    # failure (as some version of `actool` report error to stdout, yet exit
294    # with an return code of zero).
295    stdout = FilterCompilerOutput(stdout, relative_paths)
296    if stdout:
297      sys.stderr.write(stdout)
298      sys.exit(1)
299
300  finally:
301    if temporary_file:
302      temporary_file.close()
303
304
305def Main():
306  parser = argparse.ArgumentParser(
307      description='compile assets catalog for a bundle')
308  parser.add_argument('--platform',
309                      '-p',
310                      required=True,
311                      choices=('mac', 'ios', 'watchos'),
312                      help='target platform for the compiled assets catalog')
313  parser.add_argument('--target-environment',
314                      '-e',
315                      default='',
316                      choices=('simulator', 'device', 'catalyst'),
317                      help='target environment for the compiled assets catalog')
318  parser.add_argument(
319      '--minimum-deployment-target',
320      '-t',
321      required=True,
322      help='minimum deployment target for the compiled assets catalog')
323  parser.add_argument('--output',
324                      '-o',
325                      required=True,
326                      help='path to the compiled assets catalog')
327  parser.add_argument('--compress-pngs',
328                      '-c',
329                      action='store_true',
330                      default=False,
331                      help='recompress PNGs while compiling assets catalog')
332  parser.add_argument('--product-type',
333                      '-T',
334                      help='type of the containing bundle')
335  parser.add_argument('--partial-info-plist',
336                      '-P',
337                      help='path to partial info plist to create')
338  parser.add_argument('--app-icon',
339                      '-A',
340                      help='name of an app icon set for the target’s default app icon')
341  parser.add_argument('--include-all-app-icons',
342                      '-I',
343                      action='store_true',
344                      default=False,
345                      help='include all app icons in the compiled assets catalog')
346  parser.add_argument('inputs',
347                      nargs='+',
348                      help='path to input assets catalog sources')
349  args = parser.parse_args()
350
351  if os.path.basename(args.output) != 'Assets.car':
352    sys.stderr.write('output should be path to compiled asset catalog, not '
353                     'to the containing bundle: %s\n' % (args.output, ))
354    sys.exit(1)
355
356  if os.path.exists(args.output):
357    if os.path.isfile(args.output):
358      os.unlink(args.output)
359    else:
360      shutil.rmtree(args.output)
361
362  with tempfile.TemporaryDirectory() as temporary_dir:
363    CompileAssetCatalog(args.output, args.platform, args.target_environment,
364                        args.product_type, args.minimum_deployment_target,
365                        args.inputs, args.compress_pngs,
366                        args.partial_info_plist, args.app_icon,
367                        args.include_all_app_icons, temporary_dir)
368
369
370if __name__ == '__main__':
371  sys.exit(Main())
372