| #!/usr/bin/env python3 |
| |
| # Copyright 2025 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """A script to compile asset catalogs into .car files for Chromium Mac. |
| |
| The Chromium build system has the ability to automatically compile `.xcassets` |
| and `.icon` files into `.car` files and include them into outputs; see |
| //build/toolchain/apple/compile_xcassets.py and |
| https://gn.googlesource.com/gn/+/main/docs/reference.md#func_bundle_data. |
| However, `actool` is Mac-only, and has a wild amount of dependencies on the rest |
| of the Xcode package (`ibtoold` specifically, which has 70+ different |
| dependencies, mostly frameworks), so pre-compiled `.car` files are checked in |
| instead. |
| |
| Full documentation can be found at //docs/mac/icons.md but briefly: |
| |
| $ python3 compile_car.py chrome/app/theme/chromium/mac/Assets.xcasset |
| """ |
| |
| import argparse |
| import os |
| import pathlib |
| import plistlib |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| import typing |
| |
| |
| class AssetCatalogException(Exception): |
| pass |
| |
| |
| def _split_version(version: str) -> tuple[int, ...]: |
| return tuple(int(x) for x in version.split('.')) |
| |
| |
| def _unsplit_version(version: tuple[int, ...]) -> str: |
| return '.'.join((str(x) for x in version)) |
| |
| |
| # A minimum `actool` version. It's likely that versions from earlier releases of |
| # Xcode 26 would work, but they are untested. This is the last version (as of |
| # writing, mid-August 2025) that is capable of emitting a compatibility `.icns` |
| # file. |
| _ACTOOL_26B4_VERSION = _split_version('24112') |
| # The `actool` version of Xcode 26.0. |
| _ACTOOL_26_VERSION = _split_version('24127') |
| |
| |
| class CompatibilityExpectations: |
| __slots__ = [ |
| # Through Xcode 26b6, the `--enable-icon-stack-fallback-generation` + |
| # `--include-all-app-icons` workaround can be used to include existing |
| # app icon bitmaps for use on macOS <26 while including the macOS 26 |
| # icon. Xcode 26b4 also correctly includes a backwards-compatibility |
| # `.icns` file, but Xcode 26b5 and later no longer include that file and |
| # instead log an error. See FB19772616 for a general filing of |
| # grievances about being unable to specify bitmaps, though (for obvious |
| # reasons) it doesn't go into detail about undocumented command-line |
| # flags. |
| # |
| # This slot is set to `True` if `actool` is expected to create an |
| # `.icns` file, `False` if it is not expected to create the file, and |
| # `None` if it is unknown whether or not the file will be created. |
| 'icns_expected' |
| ] |
| |
| |
| def _verify_actool_version() -> CompatibilityExpectations: |
| """Verifies that the `actool` being used is suitably recent. |
| |
| Returns: |
| A CompatibilityExpectations object |
| |
| Raises: |
| AssetCatalogException: If `actool` is too old |
| """ |
| command = ['xcrun', 'actool', '--output-format=xml1', '--version'] |
| process = subprocess.check_output(command) |
| output_dict = plistlib.loads(process) |
| |
| version = _split_version( |
| output_dict['com.apple.actool.version']['bundle-version']) |
| |
| if version < _ACTOOL_26B4_VERSION: |
| raise AssetCatalogException( |
| f'actool is too old; it is version {_unsplit_version(version)} but ' |
| f'at least version {_unsplit_version(_ACTOOL_26B4_VERSION)} is ' |
| 'required. Install at least Xcode 26b4.') |
| |
| compatibility_expectations = CompatibilityExpectations() |
| |
| if version == _ACTOOL_26B4_VERSION: |
| compatibility_expectations.icns_expected = True |
| elif version <= _ACTOOL_26_VERSION: |
| print( |
| '⚠️ Compatibility warning: the active version of `actool` will ' |
| 'not emit an\n`.icns` file. If an `.icns` file is required, use ' |
| 'Xcode 26b4.', |
| file=sys.stderr) |
| compatibility_expectations.icns_expected = False |
| else: |
| print( |
| '⚠️ Compatibility warning: it is unknown if the active version ' |
| 'of `actool`\nwill emit an `.icns` file. If an `.icns` file is ' |
| 'required, use Xcode 26b4.', |
| file=sys.stderr) |
| compatibility_expectations.icns_expected = None |
| |
| return compatibility_expectations |
| |
| |
| def _min_deployment_target() -> str: |
| """Determines and returns the minimum deployment target, as determined by |
| the `mac_deployment_target` value in the //build/config/mac/mac_sdk.gni |
| file. |
| |
| Returns: |
| The minimum deployment target, as a string value. |
| """ |
| src_root = pathlib.Path(__file__).parent.joinpath(*((os.pardir,) * 3)) |
| mac_sdk_path = src_root.joinpath('build', 'config', 'mac', 'mac_sdk.gni') |
| |
| with open(mac_sdk_path, 'r') as mac_sdk_file: |
| match, = re.finditer( |
| r'^\s*mac_deployment_target\s*=\s*"(.*)"(?:\s*#.*)?$', |
| mac_sdk_file.read(), re.MULTILINE) |
| return match.group(1) |
| |
| |
| def _process_path(path: pathlib.Path, min_deployment_target: str, |
| compatibility_expectations: CompatibilityExpectations, |
| verbose: bool) -> None: |
| """Compiles a single `.xcassets` directory and `.icon` into a .car file. |
| |
| This function invokes `actool` to compile the given `.xcassets` directory |
| and parallel .icon file. It handles the output from `actool`, checks for |
| errors and unexpected warnings/notices, and copies the resulting `app.icns` |
| and `Assets.car` files to the same directory as the input `.xcassets` |
| directory, with names derived from the input path. |
| |
| Args: |
| path: A pathlib.Path object to the .xcassets directory to process. |
| min_deployment_target: The minimum macOS deployment target string to |
| pass to `actool`. |
| |
| Raises: |
| ValueError: If the asset catalog's path is incorrect in format. |
| AssetCatalogException: If `actool` reported errors, or behaved in a way |
| that was unexpected. |
| """ |
| with tempfile.TemporaryDirectory() as tmp_dir: |
| tmp_dir = pathlib.Path(tmp_dir) |
| tmp_plist = tmp_dir.joinpath('partial.plist') |
| |
| # The "tag" is the '_beta' etc channel indicator in the file name. |
| if not path.suffix == '.xcassets': |
| raise ValueError( |
| 'Asset catalog filename must have .xcassets suffix') |
| name_parts = path.stem.split('_') |
| if len(name_parts) > 2: |
| raise ValueError('Asset catalog filename must have at most one _') |
| name_tag = f'_{name_parts[1]}' if len(name_parts) == 2 else '' |
| source_dir = path.joinpath(os.pardir) |
| |
| # The app icon is copied into the .car file under the name that it has |
| # when given to actool, so make a copy in the temp directory to ensure |
| # it has the correct name. `shutil.copytree` is used as .icon "files" |
| # are really packages. |
| appicon_original_path = source_dir.joinpath(f'AppIcon{name_tag}.icon') |
| appicon_tmp_path = tmp_dir.joinpath('AppIcon.icon') |
| shutil.copytree(appicon_original_path, appicon_tmp_path) |
| |
| command = [ |
| # The binary. |
| 'xcrun', |
| 'actool', |
| |
| # Output and error handling. |
| '--output-format=xml1', |
| '--notices', |
| '--warnings', |
| '--errors', |
| |
| # Platform. |
| '--platform=macosx', |
| '--target-device=mac', |
| |
| # Correctness. This command-line argument is undocumented. It forces |
| # `actool` aka `ibtool` to use bundled versions of the asset catalog |
| # frameworks so that it generates consistent results no matter what |
| # OS release it is run on. Xcode 26+ includes this when invoking |
| # `actool`; see various copies of the `AssetCatalogCompiler.xcspec` |
| # file found in various places inside the Xcode package. |
| '--lightweight-asset-runtime-mode=enabled', |
| |
| # Correctness. The `--enable-icon-stack-fallback-generation` |
| # command-line argument is undocumented. By default, if an `.icon` |
| # file is provided to `actool`, then `actool` will ignore any |
| # corresponding fallback bitmaps in the provided `.xcassets` |
| # directory, and generate its own. With that command-line argument |
| # specified, `actool` will use the ones in the provided `.xcassets` |
| # directory instead. |
| # |
| # With Xcode 26b4, the first command-line argument is all that is |
| # needed, and a backward-compatibility `.icns` file will be |
| # generated. With 26b5 and 26b6, adding the |
| # `--include-all-app-icons` argument is required to include the |
| # provided fallback bitmaps, and even then there will be no `.icns` |
| # file generated. (Adding that argument is harmless on 26b4.) |
| # |
| # See the discussion at |
| # https://mjtsai.com/blog/2025/08/08/separate-icons-for-macos-tahoe-vs-earlier/ |
| # and specifically the toots at |
| # https://mas.to/@avidrissman/114989207727177911 and |
| # https://mastodon.social/@vslavik/115016258774715162 . |
| '--enable-icon-stack-fallback-generation=disabled', |
| '--include-all-app-icons', |
| |
| # Target information. |
| '--app-icon=AppIcon', |
| f'--minimum-deployment-target={min_deployment_target}', |
| |
| # Where to place the outputs. |
| f'--output-partial-info-plist={tmp_plist}', |
| f'--compile={tmp_dir}', |
| |
| # What to compile. |
| path, |
| appicon_tmp_path, |
| ] |
| if verbose: |
| print(f' Invoking: {" ".join((str(item) for item in command))}') |
| |
| process = subprocess.Popen( |
| command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| output, stderr = process.communicate() |
| output_dict = plistlib.loads(output) |
| |
| failures = {} |
| if process.returncode != 0: |
| failures['return code'] = f'{process.returncode}' |
| |
| def collect_failures(output_dict: dict[str, typing.Any], |
| output_dict_key: str, |
| failures: dict[str, list], |
| failures_key: str, |
| filter=None) -> None: |
| value = output_dict.get(output_dict_key) |
| if value is not None and filter is not None: |
| value = filter(value) |
| if not value: # all implicitly false values |
| return |
| failures[failures_key] = value |
| |
| collect_failures(output_dict, 'com.apple.actool.errors', failures, |
| 'errors') |
| collect_failures(output_dict, 'com.apple.actool.document.errors', |
| failures, 'document errors') |
| collect_failures(output_dict, 'com.apple.actool.warnings', failures, |
| 'warnings') |
| # Some warnings are expected, so swallow those. Raise all others. |
| # TODO(avi): Remove the "ambiguous content" warning exception when |
| # switching to `actool`-generated fallback bitmaps. |
| # |
| # If an `.icns` file is expected, then raise on errors to create it. If |
| # the file is not expected, swallow the error that is generated when |
| # trying to create it, as that error is expected. |
| if compatibility_expectations.icns_expected: |
| filter = lambda warnings: [ |
| warning for warning in warnings |
| if warning['type'] != 'Ambiguous Content' |
| ] |
| else: |
| filter = lambda warnings: [ |
| warning for warning in warnings |
| if warning['type'] != 'Ambiguous Content' and not (warning[ |
| 'type'] == 'Unsupported Configuration' and warning[ |
| 'message'].startswith( |
| 'Failed to generate flattened icon stack')) |
| ] |
| collect_failures(output_dict, 'com.apple.actool.document.warnings', |
| failures, 'document warnings', filter) |
| # Weirdly, actool classifies any missing input files as a "notice", so |
| # fail upon any "notices". |
| collect_failures(output_dict, 'com.apple.actool.notices', failures, |
| 'notices') |
| collect_failures(output_dict, 'com.apple.actool.document.notices', |
| failures, 'document notices') |
| |
| if failures: |
| # stderr is usually ignorable spew, but include it in the exception |
| # if something went wrong. |
| failures['stderr'] = stderr |
| raise AssetCatalogException(f'actool failed; {failures}') |
| |
| compilation_results = output_dict.get( |
| 'com.apple.actool.compilation-results') |
| if compilation_results is None: |
| raise AssetCatalogException('actool had no compilation results') |
| output_files = compilation_results.get('output-files') |
| if output_files is None: |
| raise AssetCatalogException('actool had no output files') |
| if compatibility_expectations.icns_expected is not None: |
| expected_file_count = (3 if compatibility_expectations.icns_expected |
| else 2) |
| if len(output_files) != expected_file_count: |
| raise AssetCatalogException( |
| f'expected actool to output {expected_file_count} files, ' |
| f'but it instead output {len(output_files)} files, namely ' |
| f'{output_files}') |
| |
| # Exactly three output files are expected; handle them each |
| # appropriately. |
| for output_file in output_files: |
| output_file = pathlib.Path(output_file) |
| if output_file.name == 'partial.plist': |
| # Ignore the partial plist, as the Chromium plist already has |
| # the required information. |
| pass |
| elif output_file.name == 'AppIcon.icns': |
| if compatibility_expectations.icns_expected is None: |
| print( |
| '⚠️ `.icns` file generated; FB19772616 addressed?', |
| file=sys.stderr) |
| destination_path = source_dir.joinpath(f'app{name_tag}.icns') |
| if verbose: |
| print(f' Copying output to: {destination_path}') |
| shutil.copyfile(output_file, destination_path) |
| pass |
| elif output_file.name == 'Assets.car': |
| destination_path = source_dir.joinpath(f'Assets{name_tag}.car') |
| if verbose: |
| print(f' Copying output to: {destination_path}') |
| shutil.copyfile(output_file, destination_path) |
| else: |
| raise AssetCatalogException( |
| f'Unexpected output file: {output_file}') |
| |
| |
| def main(args: list[str]): |
| compatibility_expectations = _verify_actool_version() |
| |
| parser = argparse.ArgumentParser() |
| parser.add_argument( |
| 'paths', |
| nargs='+', |
| metavar='path', |
| help='the path to the .xcassets to process') |
| parser.add_argument( |
| '-v', |
| '--verbose', |
| dest='verbose', |
| action='store_true', |
| help='enable verbose output') |
| parsed = parser.parse_args(args) |
| |
| min_deployment_target = _min_deployment_target() |
| if parsed.verbose: |
| print('Determined the minimum deployment target to be ' |
| f'{min_deployment_target}.') |
| |
| for path in parsed.paths: |
| if parsed.verbose: |
| print(f'Processing: {path}') |
| _process_path( |
| pathlib.Path(path), min_deployment_target, |
| compatibility_expectations, parsed.verbose) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main(sys.argv[1:])) |