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