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