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