blob: 0e1af638cd7cca4d2d3da80336c295bbf0618770 [file] [log] [blame]
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Convert GN Xcode projects to platform and configuration independent targets.
GN generates Xcode projects that build one configuration only. However, typical
iOS development involves using the Xcode IDE to toggle the platform and
configuration. This script replaces the 'gn' configuration with 'Debug',
'Release' and 'Profile', and changes the ninja invokation to honor these
import argparse
import collections
import copy
import filecmp
import json
import hashlib
import os
import plistlib
import random
import shutil
import subprocess
import sys
import tempfile
class XcodeProject(object):
def __init__(self, objects, counter = 0):
self.objects = objects
self.counter = 0
def AddObject(self, parent_name, obj):
while True:
self.counter += 1
str_id = "%s %s %d" % (parent_name, obj['isa'], self.counter)
new_id = hashlib.sha1(str_id).hexdigest()[:24].upper()
# Make sure ID is unique. It's possible there could be an id conflict
# since this is run after GN runs.
if new_id not in self.objects:
self.objects[new_id] = obj
return new_id
def CopyFileIfChanged(source_path, target_path):
"""Copy |source_path| to |target_path| is different."""
target_dir = os.path.dirname(target_path)
if not os.path.isdir(target_dir):
if not os.path.exists(target_path) or \
not filecmp.cmp(source_path, target_path):
shutil.copyfile(source_path, target_path)
def LoadXcodeProjectAsJSON(path):
"""Return Xcode project at |path| as a JSON string."""
return subprocess.check_output([
'plutil', '-convert', 'json', '-o', '-', path])
def WriteXcodeProject(output_path, json_string):
"""Save Xcode project to |output_path| as XML."""
with tempfile.NamedTemporaryFile() as temp_file:
subprocess.check_call(['plutil', '-convert', 'xml1',])
CopyFileIfChanged(, output_path)
def UpdateProductsProject(file_input, file_output, configurations):
"""Update Xcode project to support multiple configurations.
file_input: path to the input Xcode project
file_output: path to the output file
configurations: list of string corresponding to the configurations that
need to be supported by the tweaked Xcode projects, must contains at
least one value.
json_data = json.loads(LoadXcodeProjectAsJSON(file_input))
project = XcodeProject(json_data['objects'])
objects_to_remove = []
for value in project.objects.values():
isa = value['isa']
# TODO( gn does not write the min deployment target in the
# generated Xcode project, so add it while doing the conversion, only if it
# is not present. Remove this code and comment once the bug is fixed and gn
# has rolled past it.
if isa == 'XCBuildConfiguration':
build_settings = value['buildSettings']
if 'IPHONEOS_DEPLOYMENT_TARGET' not in build_settings:
build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0'
# Teach build shell script to look for the configuration and platform.
if isa == 'PBXShellScriptBuildPhase':
value['shellScript'] = value['shellScript'].replace(
'ninja -C .',
# Configure BUNDLE_LOADER and TEST_HOST for xctest targets (if not yet
# configured by gn). Old convention was to name the test dynamic module
# "foo" and the host "foo_host" while the new convention is to name the
# test "foo_module" and the host "foo". Decide which convention to use
# by inspecting the target name.
if isa == 'PBXNativeTarget' and value['productType'] == XCTEST_PRODUCT_TYPE:
configuration_list = project.objects[value['buildConfigurationList']]
for config_name in configuration_list['buildConfigurations']:
config = project.objects[config_name]
if not config['buildSettings'].get('BUNDLE_LOADER'):
assert value['name'].endswith('_module')
host_name = value['name'][:-len('_module')]
config['buildSettings']['BUNDLE_LOADER'] = '$(TEST_HOST)'
config['buildSettings']['TEST_HOST'] = \
'${BUILT_PRODUCTS_DIR}/' % (host_name, host_name)
# Add new configuration, using the first one as default.
if isa == 'XCConfigurationList':
value['defaultConfigurationName'] = configurations[0]
build_config_template = project.objects[value['buildConfigurations'][0]]
build_config_template['buildSettings']['CONFIGURATION_BUILD_DIR'] = \
value['buildConfigurations'] = []
for configuration in configurations:
new_build_config = copy.copy(build_config_template)
new_build_config['name'] = configuration
project.AddObject('products', new_build_config))
for object_id in objects_to_remove:
del project.objects[object_id]
objects = collections.OrderedDict(sorted(project.objects.iteritems()))
WriteXcodeProject(file_output, json.dumps(json_data))
def ConvertGnXcodeProject(input_dir, output_dir, configurations):
'''Tweak the Xcode project generated by gn to support multiple configurations.
The Xcode projects generated by "gn gen --ide" only supports a single
platform and configuration (as the platform and configuration are set
per output directory). This method takes as input such projects and
add support for multiple configurations and platforms (to allow devs
to select them in Xcode).
input_dir: directory containing the XCode projects created by "gn gen --ide"
output_dir: directory where the tweaked Xcode projects will be saved
configurations: list of string corresponding to the configurations that
need to be supported by the tweaked Xcode projects, must contains at
least one value.
# Update products project.
products = os.path.join('products.xcodeproj', 'project.pbxproj')
product_input = os.path.join(input_dir, products)
product_output = os.path.join(output_dir, products)
UpdateProductsProject(product_input, product_output, configurations)
# Copy all workspace.
xcwspace = os.path.join('all.xcworkspace', 'contents.xcworkspacedata')
CopyFileIfChanged(os.path.join(input_dir, xcwspace),
os.path.join(output_dir, xcwspace))
# TODO( gn has been modified to remove 'sources.xcodeproj'
# and keep 'all.xcworkspace' and 'products.xcodeproj'. The following code is
# here to support both old and new projects setup and will be removed once gn
# has rolled past it.
sources = os.path.join('sources.xcodeproj', 'project.pbxproj')
if os.path.isfile(os.path.join(input_dir, sources)):
CopyFileIfChanged(os.path.join(input_dir, sources),
os.path.join(output_dir, sources))
def Main(args):
parser = argparse.ArgumentParser(
description='Convert GN Xcode projects for iOS.')
help='directory containing [product|all] Xcode projects.')
help='directory where to generate the iOS configuration.')
'--add-config', dest='configurations', default=[], action='append',
help='configuration to add to the Xcode project')
args = parser.parse_args(args)
if not os.path.isdir(args.input):
sys.stderr.write('Input directory does not exists.\n')
return 1
required = set(['products.xcodeproj', 'all.xcworkspace'])
if not required.issubset(os.listdir(args.input)):
'Input directory does not contain all necessary Xcode projects.\n')
return 1
if not args.configurations:
sys.stderr.write('At least one configuration required, see --add-config.\n')
return 1
ConvertGnXcodeProject(args.input, args.output, args.configurations)
if __name__ == '__main__':