| #!/usr/bin/env python |
| # |
| # Copyright 2014-present Facebook, Inc. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may |
| # not use this file except in compliance with the License. You may obtain |
| # a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations |
| # under the License. |
| # |
| |
| """This script will compile an Xcode 5 asset catalog (*.xcassets) in a fashion |
| that is compatible with legacy bundle placement. For instance, asset catalogs |
| flatten all assets into a single namespace and all assets will be placed in |
| the main application bundle's resource path's root. However, when using a |
| bundle, the bundle's directory would be placed in the same place, thus |
| allowing for a hierarchy. |
| |
| For example, when building an application called Sample.app that references a |
| Resource.bundle in its Copy Resources build phase, the files in the bundle |
| would be placed at Sample.app/Resource.bundle/<files>. If the bundle's image |
| assets are moved to an asset catalog, the files are placed in at |
| Sample.app/<files>. This creates namespacing issues for code that references |
| these resources. |
| |
| This script manipulates the asset catalog compiler (actool) to place resources |
| in the expected place.""" |
| |
| import optparse |
| import errno |
| import logging |
| import os |
| import os.path |
| import re |
| import subprocess |
| import sys |
| |
| import StringIO |
| |
| logger = logging.getLogger('compile_asset_catalog') |
| |
| |
| def get_xcode_path(): |
| cmd = ['xcode-select', '--print-path'] |
| return subprocess.check_output(cmd).strip() |
| |
| |
| def get_actool_env_path(platform): |
| platform = 'iPhoneSimulator' |
| if platform != 'iphonesimulator': |
| platform = 'iPhoneOS' |
| xcode_path = get_xcode_path() |
| return ':'.join([ |
| os.path.join( |
| xcode_path, |
| 'Platforms', |
| platform + '.platform', |
| 'Developer/usr/bin/actool'), |
| os.path.join(xcode_path, 'usr/bin'), |
| '/usr/bin', |
| '/bin', |
| '/usr/sbin', |
| '/sbin']) |
| |
| |
| def version_components_from_string(v): |
| return v.split('.') |
| |
| |
| def major_version_from_version_string(v): |
| return int(version_components_from_string(v)[0]) |
| |
| |
| def minor_version_from_version_string(v): |
| components = version_components_from_string(v) |
| if len(components) < 2: |
| raise ValueError(v + ': does not have a minor version') |
| return int(components[1]) |
| |
| |
| def catalog_name_from_path(path): |
| return os.path.splitext(os.path.basename(path))[0] |
| |
| |
| def actool_cmds(target, platform, devices, output, catalog_paths, |
| split_into_bundles): |
| """Returns an array of tuples. Each tuple is (command, output_directory). |
| command is an array of command line parameters, while directory is a |
| directory that is expected to exist.""" |
| |
| # When splitting into bundles, Assets.car cannot be used, so force the |
| # deployment target to a version that does not support it. |
| if split_into_bundles: |
| if (platform == 'macosx' and |
| minor_version_from_version_string(target) >= 9): |
| target = '10.8' |
| elif major_version_from_version_string(target) >= 7: |
| target = '6.0' |
| |
| base_cmd = [ |
| 'actool', |
| '--output-format', |
| 'human-readable-text', |
| '--notices', |
| '--warnings', |
| '--platform', |
| platform, |
| '--minimum-deployment-target', |
| target] |
| |
| for d in devices: |
| base_cmd.extend(['--target-device', d]) |
| |
| base_cmd.extend(['--compress-pngs', '--compile']) |
| |
| if split_into_bundles: |
| bundle_directories = [ |
| catalog_name_from_path(path) + '.bundle' for path in catalog_paths] |
| output_directories = [ |
| os.path.join(output, bundle) for bundle in bundle_directories] |
| |
| pairs = zip(output_directories, catalog_paths) |
| cmds = [] |
| for (output_directory, catalog_path) in pairs: |
| cmd = list(base_cmd) |
| cmd.extend([output_directory, catalog_path]) |
| cmds.append(cmd) |
| |
| return zip(cmds, output_directories) |
| else: |
| base_cmd.append(output) |
| base_cmd.extend(catalog_paths) |
| return [(base_cmd, output)] |
| |
| |
| def mkdir_p(path): |
| try: |
| os.makedirs(path) |
| except OSError as e: |
| if e.errno == errno.EEXIST and os.path.isdir(path): |
| pass |
| else: |
| raise |
| |
| |
| # We don't know all the different types of potential warnings actool outputs. |
| # We assume that they have some potential preamble, then "warning:" and then |
| # the message. In the case that the preamble is a path, we truncate the path |
| # to a suffix that indicates the asset name to make it easier to debug in |
| # Xcode. |
| ACTOOL_WARNING_REGEX = re.compile( |
| '(?P<asset_path>.*:)?\s*warning:\s*(?P<message>.*)') |
| |
| |
| def transform_actool_output_line(line): |
| line = line.rstrip() |
| |
| # Xcode seems to ignore actool's warnings when they are piped through |
| # stdout as normal. We want to treat these as errors that must be |
| # resolved. This filter formats the warnings nicely to make it easy to |
| # resolve them. |
| # |
| # Sample: |
| # /path/to/images.xcassets:./some.imageset: warning: ... |
| # should be: |
| # error: images.xcassets:./some.imageset: warning: ... |
| matches = ACTOOL_WARNING_REGEX.match(line) |
| if matches: |
| groups = matches.groupdict() |
| if (groups['asset_path']): |
| groups['asset_path'] = os.path.basename(groups['asset_path']) |
| line = 'error: ' + groups['asset_path'] + ': ' + groups['message'] |
| return line |
| |
| |
| def transform_actool_output(stdout, verbose): |
| has_errors = False |
| is_error = False |
| |
| # Using `for line in proc.stdout` causes OS X to barf with errno=35. Not |
| # sure why, but it appears to be a rate limiting issue. Creating the loop |
| # manually appears to resolve the issue. |
| line = stdout.readline() |
| while line: |
| line = transform_actool_output_line(line) |
| if line.startswith('error:'): |
| is_error = True |
| has_errors = True |
| if is_error or verbose: |
| print line |
| line = stdout.readline() |
| return not has_errors |
| |
| |
| def compile_asset_catalogs(target, platform, devices, output, catalogs, |
| split_into_bundles, verbose): |
| cmd_pairs = actool_cmds( |
| target, |
| platform, |
| devices, |
| output, |
| catalogs, |
| split_into_bundles) |
| actool_env_path = get_actool_env_path(platform) |
| |
| env = os.environ.copy() |
| env['PATH'] = actool_env_path |
| |
| errors_encountered = False |
| |
| for (cmd, output_directory) in cmd_pairs: |
| mkdir_p(output_directory) |
| |
| logger.info('PATH=' + actool_env_path + ' ' + ' '.join(cmd)) |
| |
| # The explicit PATH is provided because Xcode runs actool with an |
| # explicit PATH when it runs as part of the automatic "Copy Bundle |
| # Resources" phase. This ensures that the tool is run in the same |
| # environment as expected. |
| # |
| # Note that check_output raises an exception if the exit code |
| # indicates an error, which is the desired behavior here. |
| actool_output = subprocess.check_output(cmd, env=env) |
| actool_stdout = StringIO.StringIO(actool_output) |
| success = transform_actool_output(actool_stdout, verbose) |
| |
| if not success: |
| errors_encountered = True |
| |
| return not errors_encountered |
| |
| |
| if __name__ == "__main__": |
| parser = optparse.OptionParser() |
| parser.add_option('-t', '--target', |
| help='Target operating system version for deployment') |
| parser.add_option('-p', '--platform', |
| help='Target platform. Choices are iphonesimulator, ' |
| 'iphoneos, and macosx.') |
| parser.add_option('-d', '--device', action='append', type=str, |
| help='Choices are iphone and ipad. May be specified ' |
| 'multiple times. When platform is macosx, this ' |
| 'option cannot be specified. Otherwise, this option ' |
| 'must be specified.') |
| parser.add_option('-b', '--bundles', action='store_true', |
| help='Use the legacy output format, which copies ' |
| 'asset catalogs to their sibling bundles. Without ' |
| 'this option, all assets are copied to the root (or ' |
| 'compiled into Assets.car)') |
| parser.add_option('-o', '--output', |
| help='Output directory for the specified asset ' |
| 'catalog(s).') |
| parser.add_option('-v', '--verbose', action='store_true', |
| help='Print verbose output') |
| opts, args = parser.parse_args() |
| |
| logging.basicConfig( |
| level=(logging.DEBUG if opts.verbose else logging.INFO)) |
| logger.info('Compiling asset catalogs...') |
| |
| catalogs = map(os.path.abspath, args) |
| opts.output = os.path.abspath(opts.output) |
| |
| # Validation: |
| # |
| # - deployment target is a version string |
| # - platform is one of the appropriate choices |
| # - when platform is macosx, devices are not specified. Otherwise, |
| # devices must be specified |
| # - devices are valid values |
| # - asset catalogs paths end in .xcassets |
| for component in opts.target.split('.'): |
| try: |
| int(component) |
| except: |
| raise ValueError(opts.target + ': target must be a version string') |
| |
| if (opts.platform != 'iphonesimulator' and opts.platform != 'iphoneos' |
| and opts.platform != 'macosx'): |
| raise ValueError(opts.platform + ': platform must be either ' |
| 'iphoneos, iphonesimulator, or macosx') |
| |
| if opts.platform == 'macosx' and opts.device is not None: |
| raise ValueError( |
| 'devices must not be specified when platform is macosx') |
| elif opts.platform != 'macosx' and (opts.device or len(opts.device) == 0): |
| raise ValueError('devices must be specified when platform is iphoneos ' |
| 'or iphonesimulator') |
| |
| if opts.device is not None: |
| for device in opts.device: |
| if device != 'iphone' and device != 'ipad': |
| raise ValueError( |
| device + ': device(s) must be either iphone or ipad') |
| |
| for path in catalogs: |
| if os.path.splitext(os.path.basename(path))[1] != '.xcassets': |
| raise ValueError( |
| path + ': catalog paths must have an xcassets extension') |
| |
| # When the target platform is macosx, the device supplied to actool is |
| # 'mac' |
| if opts.platform == 'macosx': |
| opts.device = ['mac'] |
| |
| exit_code = 0 |
| if not compile_asset_catalogs(opts.target, opts.platform, opts.device, |
| opts.output, catalogs, |
| opts.bundles, opts.verbose): |
| exit_code = 1 |
| |
| logger.info('Done') |
| sys.exit(exit_code) |