Add iOS gn configs.

Allows more native iOS development with Xcode by auto generating all
the various configs during runhooks.

Change-Id: I840001caabc7ef656c3145b847cee5596335aa23
Reviewed-on: https://chromium-review.googlesource.com/c/crashpad/crashpad/+/2024186
Reviewed-by: Rohit Rao <rohitrao@chromium.org>
Reviewed-by: Mark Mentovai <mark@chromium.org>
Commit-Queue: Justin Cohen <justincohen@chromium.org>
This commit is contained in:
Justin Cohen 2020-01-28 15:34:34 -05:00 committed by Commit Bot
parent 23a2da5e95
commit 77baffaf93
4 changed files with 678 additions and 1 deletions

15
DEPS
View File

@ -15,7 +15,11 @@
vars = {
'chromium_git': 'https://chromium.googlesource.com',
'pull_linux_clang': False,
'pull_win_toolchain': False
'pull_win_toolchain': False,
# Controls whether crashpad/build/ios/setup-ios-gn.py is run as part of
# gclient hooks. It is enabled by default for developer's convenience. It can
# be disabled with custom_vars (done automatically on the bots).
'run_setup_ios_gn': True,
}
deps = {
@ -217,6 +221,15 @@ hooks = [
'crashpad/build/install_linux_sysroot.py',
],
},
{
'name': 'setup_gn_ios',
'pattern': '.',
'condition': 'run_setup_ios_gn and checkout_ios',
'action': [
'python',
'crashpad/build/ios/setup-ios-gn.py'
],
},
]
recursedeps = [

View File

@ -0,0 +1,273 @@
#!/usr/bin/env python
# Copyright 2020 The Crashpad Authors. All rights reserved.
#
# 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.
"""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 invocation to honor these
configurations.
"""
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):
os.makedirs(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:
temp_file.write(json_string)
temp_file.flush()
subprocess.check_call(['plutil', '-convert', 'xml1', temp_file.name])
CopyFileIfChanged(temp_file.name, output_path)
def UpdateProductsProject(file_input, file_output, configurations, root_dir):
"""Update Xcode project to support multiple configurations.
Args:
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']
# Teach build shell script to look for the configuration and platform.
if isa == 'PBXShellScriptBuildPhase':
value['shellScript'] = value['shellScript'].replace(
'ninja -C .',
'ninja -C "../${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}"')
# Add new configuration, using the first one as default.
if isa == 'XCConfigurationList':
value['defaultConfigurationName'] = configurations[0]
objects_to_remove.extend(value['buildConfigurations'])
build_config_template = project.objects[value['buildConfigurations'][0]]
build_config_template['buildSettings']['CONFIGURATION_BUILD_DIR'] = \
'$(PROJECT_DIR)/../$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)'
build_config_template['buildSettings']['CODE_SIGN_IDENTITY'] = ''
value['buildConfigurations'] = []
for configuration in configurations:
new_build_config = copy.copy(build_config_template)
new_build_config['name'] = configuration
value['buildConfigurations'].append(
project.AddObject('products', new_build_config))
for object_id in objects_to_remove:
del project.objects[object_id]
AddMarkdownToProject(project, root_dir, json_data['rootObject'])
objects = collections.OrderedDict(sorted(project.objects.iteritems()))
WriteXcodeProject(file_output, json.dumps(json_data))
def AddMarkdownToProject(project, root_dir, root_object):
list_files_cmd = ['git', '-C', root_dir, 'ls-files', '*.md']
paths = subprocess.check_output(list_files_cmd).splitlines()
ios_internal_dir = os.path.join(root_dir, 'ios_internal')
if os.path.exists(ios_internal_dir):
list_files_cmd = ['git', '-C', ios_internal_dir, 'ls-files', '*.md']
ios_paths = subprocess.check_output(list_files_cmd).splitlines()
paths.extend(["ios_internal/" + path for path in ios_paths])
for path in paths:
new_markdown_entry = {
"fileEncoding": "4",
"isa": "PBXFileReference",
"lastKnownFileType": "net.daringfireball.markdown",
"name": os.path.basename(path),
"path": path,
"sourceTree": "<group>"
}
new_markdown_entry_id = project.AddObject('sources', new_markdown_entry)
folder = GetFolderForPath(project, root_object, os.path.dirname(path))
folder['children'].append(new_markdown_entry_id)
def GetFolderForPath(project, rootObject, path):
objects = project.objects
# 'Sources' is always the first child of
# project->rootObject->mainGroup->children.
root = objects[objects[objects[rootObject]['mainGroup']]['children'][0]]
if not path:
return root
for folder in path.split('/'):
children = root['children']
new_root = None
for child in children:
if objects[child]['isa'] == 'PBXGroup' and \
objects[child]['name'] == folder:
new_root = objects[child]
break
if not new_root:
# If the folder isn't found we could just cram it into the leaf existing
# folder, but that leads to folders with tons of README.md inside.
new_group = {
"children": [
],
"isa": "PBXGroup",
"name": folder,
"sourceTree": "<group>"
}
new_group_id = project.AddObject('sources', new_group)
children.append(new_group_id)
new_root = objects[new_group_id]
root = new_root
return root
def DisableNewBuildSystem(output_dir):
"""Disables the new build system due to crbug.com/852522 """
xcwspacesharedsettings = os.path.join(output_dir, 'all.xcworkspace',
'xcshareddata', 'WorkspaceSettings.xcsettings')
if os.path.isfile(xcwspacesharedsettings):
json_data = json.loads(LoadXcodeProjectAsJSON(xcwspacesharedsettings))
else:
json_data = {}
json_data['BuildSystemType'] = 'Original'
WriteXcodeProject(xcwspacesharedsettings, json.dumps(json_data))
def ConvertGnXcodeProject(root_dir, 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).
Args:
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, root_dir)
# 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(crbug.com/852522): Disable new BuildSystemType.
DisableNewBuildSystem(output_dir)
# TODO(crbug.com/679110): 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.')
parser.add_argument(
'input',
help='directory containing [product|all] Xcode projects.')
parser.add_argument(
'output',
help='directory where to generate the iOS configuration.')
parser.add_argument(
'--add-config', dest='configurations', default=[], action='append',
help='configuration to add to the Xcode project')
parser.add_argument(
'--root', type=os.path.abspath, required=True,
help='root directory of the 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)):
sys.stderr.write(
'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.root, args.input, args.output, args.configurations)
if __name__ == '__main__':
sys.exit(Main(sys.argv[1:]))

View File

@ -0,0 +1,39 @@
# Copyright 2020 The Crashpad Authors. All rights reserved.
#
# 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.
[goma]
# Controls whether goma is enabled or not. If you generally use goma but
# want to disable goma for a single build, consider using the environment
# variable GOMA_DISABLED.
enabled = False
install = "$GOMA_DIR"
[xcode]
# Controls settings for the generated Xcode project. If jobs is non-zero
# it will be passed to the ninja invocation in Xcode project.
jobs = 0
[build]
# Controls the build output. The only supported values are "64-bit", "32-bit"
# and "fat" (for a fat binary supporting both "32-bit" and "64-bit" cpus).
arch = "64-bit"
[gn_args]
# Values in that section will be copied verbatim in the generated args.gn file.
target_os = "ios"
[filters]
# List of target files to pass to --filters argument of gn gen when generating
# the Xcode project. By default, list all targets from ios/ and ios_internal/
# and the targets corresponding to the unit tests run on the bots.

352
build/ios/setup-ios-gn.py Normal file
View File

@ -0,0 +1,352 @@
#!/usr/bin/env python
# Copyright 2020 The Crashpad Authors. All rights reserved.
#
# 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.
import argparse
import convert_gn_xcodeproj
import errno
import os
import re
import shutil
import subprocess
import sys
import tempfile
import ConfigParser
try:
import cStringIO as StringIO
except ImportError:
import StringIO
SUPPORTED_TARGETS = ('iphoneos', 'iphonesimulator')
SUPPORTED_CONFIGS = ('Debug', 'Release', 'Profile', 'Official', 'Coverage')
class ConfigParserWithStringInterpolation(ConfigParser.SafeConfigParser):
'''A .ini file parser that supports strings and environment variables.'''
ENV_VAR_PATTERN = re.compile(r'\$([A-Za-z0-9_]+)')
def values(self, section):
return map(
lambda (k, v): self._UnquoteString(self._ExpandEnvVar(v)),
ConfigParser.SafeConfigParser.items(self, section))
def getstring(self, section, option):
return self._UnquoteString(self._ExpandEnvVar(self.get(section, option)))
def _UnquoteString(self, string):
if not string or string[0] != '"' or string[-1] != '"':
return string
return string[1:-1]
def _ExpandEnvVar(self, value):
match = self.ENV_VAR_PATTERN.search(value)
if not match:
return value
name, (begin, end) = match.group(1), match.span(0)
prefix, suffix = value[:begin], self._ExpandEnvVar(value[end:])
return prefix + os.environ.get(name, '') + suffix
class GnGenerator(object):
'''Holds configuration for a build and method to generate gn default files.'''
FAT_BUILD_DEFAULT_ARCH = '64-bit'
TARGET_CPU_VALUES = {
'iphoneos': {
'32-bit': '"arm"',
'64-bit': '"arm64"',
},
'iphonesimulator': {
'32-bit': '"x86"',
'64-bit': '"x64"',
}
}
def __init__(self, settings, config, target):
assert target in SUPPORTED_TARGETS
assert config in SUPPORTED_CONFIGS
self._settings = settings
self._config = config
self._target = target
def _GetGnArgs(self):
"""Build the list of arguments to pass to gn.
Returns:
A list of tuple containing gn variable names and variable values (it
is not a dictionary as the order needs to be preserved).
"""
args = []
args.append(('is_debug', self._config in ('Debug', 'Coverage')))
if os.environ.get('FORCE_MAC_TOOLCHAIN', '0') == '1':
args.append(('use_system_xcode', False))
cpu_values = self.TARGET_CPU_VALUES[self._target]
build_arch = self._settings.getstring('build', 'arch')
if build_arch == 'fat':
target_cpu = cpu_values[self.FAT_BUILD_DEFAULT_ARCH]
args.append(('target_cpu', target_cpu))
args.append(('additional_target_cpus',
[cpu for cpu in cpu_values.itervalues() if cpu != target_cpu]))
else:
args.append(('target_cpu', cpu_values[build_arch]))
# Add user overrides after the other configurations so that they can
# refer to them and override them.
args.extend(self._settings.items('gn_args'))
return args
def Generate(self, gn_path, root_path, out_path):
buf = StringIO.StringIO()
self.WriteArgsGn(buf)
WriteToFileIfChanged(
os.path.join(out_path, 'args.gn'),
buf.getvalue(),
overwrite=True)
subprocess.check_call(
self.GetGnCommand(gn_path, root_path, out_path, True))
def CreateGnRules(self, gn_path, root_path, out_path):
buf = StringIO.StringIO()
self.WriteArgsGn(buf)
WriteToFileIfChanged(
os.path.join(out_path, 'args.gn'),
buf.getvalue(),
overwrite=True)
buf = StringIO.StringIO()
gn_command = self.GetGnCommand(gn_path, root_path, out_path, False)
self.WriteBuildNinja(buf, gn_command)
WriteToFileIfChanged(
os.path.join(out_path, 'build.ninja'),
buf.getvalue(),
overwrite=False)
buf = StringIO.StringIO()
self.WriteBuildNinjaDeps(buf)
WriteToFileIfChanged(
os.path.join(out_path, 'build.ninja.d'),
buf.getvalue(),
overwrite=False)
def WriteArgsGn(self, stream):
stream.write('# This file was generated by setup-gn.py. Do not edit\n')
stream.write('# but instead use ~/.setup-gn or $repo/.setup-gn files\n')
stream.write('# to configure settings.\n')
stream.write('\n')
if self._settings.has_section('$imports$'):
for import_rule in self._settings.values('$imports$'):
stream.write('import("%s")\n' % import_rule)
stream.write('\n')
gn_args = self._GetGnArgs()
for name, value in gn_args:
if isinstance(value, bool):
stream.write('%s = %s\n' % (name, str(value).lower()))
elif isinstance(value, list):
stream.write('%s = [%s' % (name, '\n' if len(value) > 1 else ''))
if len(value) == 1:
prefix = ' '
suffix = ' '
else:
prefix = ' '
suffix = ',\n'
for item in value:
if isinstance(item, bool):
stream.write('%s%s%s' % (prefix, str(item).lower(), suffix))
else:
stream.write('%s%s%s' % (prefix, item, suffix))
stream.write(']\n')
else:
stream.write('%s = %s\n' % (name, value))
def WriteBuildNinja(self, stream, gn_command):
stream.write('rule gn\n')
stream.write(' command = %s\n' % NinjaEscapeCommand(gn_command))
stream.write(' description = Regenerating ninja files\n')
stream.write('\n')
stream.write('build build.ninja: gn\n')
stream.write(' generator = 1\n')
stream.write(' depfile = build.ninja.d\n')
def WriteBuildNinjaDeps(self, stream):
stream.write('build.ninja: nonexistant_file.gn\n')
def GetGnCommand(self, gn_path, src_path, out_path, generate_xcode_project):
gn_command = [ gn_path, '--root=%s' % os.path.realpath(src_path), '-q' ]
if generate_xcode_project:
gn_command.append('--ide=xcode')
gn_command.append('--root-target=gn_all')
if self._settings.getboolean('goma', 'enabled'):
ninja_jobs = self._settings.getint('xcode', 'jobs') or 200
gn_command.append('--ninja-extra-args=-j%s' % ninja_jobs)
if self._settings.has_section('filters'):
target_filters = self._settings.values('filters')
if target_filters:
gn_command.append('--filters=%s' % ';'.join(target_filters))
# TODO(justincohen): --check is currently failing in crashpad.
# else:
# gn_command.append('--check')
gn_command.append('gen')
gn_command.append('//%s' %
os.path.relpath(os.path.abspath(out_path), os.path.abspath(src_path)))
return gn_command
def WriteToFileIfChanged(filename, content, overwrite):
'''Write |content| to |filename| if different. If |overwrite| is False
and the file already exists it is left untouched.'''
if os.path.exists(filename):
if not overwrite:
return
with open(filename) as file:
if file.read() == content:
return
if not os.path.isdir(os.path.dirname(filename)):
os.makedirs(os.path.dirname(filename))
with open(filename, 'w') as file:
file.write(content)
def NinjaNeedEscape(arg):
'''Returns True if |arg| needs to be escaped when written to .ninja file.'''
return ':' in arg or '*' in arg or ';' in arg
def NinjaEscapeCommand(command):
'''Escapes |command| in order to write it to .ninja file.'''
result = []
for arg in command:
if NinjaNeedEscape(arg):
arg = arg.replace(':', '$:')
arg = arg.replace(';', '\\;')
arg = arg.replace('*', '\\*')
else:
result.append(arg)
return ' '.join(result)
def FindGn():
'''Returns absolute path to gn binary looking at the PATH env variable.'''
for path in os.environ['PATH'].split(os.path.pathsep):
gn_path = os.path.join(path, 'gn')
if os.path.isfile(gn_path) and os.access(gn_path, os.X_OK):
return gn_path
return None
def GenerateXcodeProject(gn_path, root_dir, out_dir, settings):
'''Convert GN generated Xcode project into multi-configuration Xcode
project.'''
temp_path = tempfile.mkdtemp(prefix=os.path.abspath(
os.path.join(out_dir, '_temp')))
try:
generator = GnGenerator(settings, 'Debug', 'iphonesimulator')
generator.Generate(gn_path, root_dir, temp_path)
convert_gn_xcodeproj.ConvertGnXcodeProject(
root_dir,
os.path.join(temp_path),
os.path.join(out_dir, 'build'),
SUPPORTED_CONFIGS)
finally:
if os.path.exists(temp_path):
shutil.rmtree(temp_path)
def GenerateGnBuildRules(gn_path, root_dir, out_dir, settings):
'''Generates all template configurations for gn.'''
for config in SUPPORTED_CONFIGS:
for target in SUPPORTED_TARGETS:
build_dir = os.path.join(out_dir, '%s-%s' % (config, target))
generator = GnGenerator(settings, config, target)
generator.CreateGnRules(gn_path, root_dir, build_dir)
def Main(args):
default_root = os.path.normpath(os.path.join(
os.path.dirname(__file__), os.pardir, os.pardir))
parser = argparse.ArgumentParser(
description='Generate build directories for use with gn.')
parser.add_argument(
'root', default=default_root, nargs='?',
help='root directory where to generate multiple out configurations')
parser.add_argument(
'--import', action='append', dest='import_rules', default=[],
help='path to file defining default gn variables')
args = parser.parse_args(args)
# Load configuration (first global and then any user overrides).
settings = ConfigParserWithStringInterpolation()
settings.read([
os.path.splitext(__file__)[0] + '.config',
os.path.expanduser('~/.setup-gn'),
])
# Add private sections corresponding to --import argument.
if args.import_rules:
settings.add_section('$imports$')
for i, import_rule in enumerate(args.import_rules):
if not import_rule.startswith('//'):
import_rule = '//%s' % os.path.relpath(
os.path.abspath(import_rule), os.path.abspath(args.root))
settings.set('$imports$', '$rule%d$' % i, import_rule)
# Validate settings.
if settings.getstring('build', 'arch') not in ('64-bit', '32-bit', 'fat'):
sys.stderr.write('ERROR: invalid value for build.arch: %s\n' %
settings.getstring('build', 'arch'))
sys.exit(1)
if settings.getboolean('goma', 'enabled'):
if settings.getint('xcode', 'jobs') < 0:
sys.stderr.write('ERROR: invalid value for xcode.jobs: %s\n' %
settings.get('xcode', 'jobs'))
sys.exit(1)
goma_install = os.path.expanduser(settings.getstring('goma', 'install'))
if not os.path.isdir(goma_install):
sys.stderr.write('WARNING: goma.install directory not found: %s\n' %
settings.get('goma', 'install'))
sys.stderr.write('WARNING: disabling goma\n')
settings.set('goma', 'enabled', 'false')
# Find gn binary in PATH.
gn_path = FindGn()
if gn_path is None:
sys.stderr.write('ERROR: cannot find gn in PATH\n')
sys.exit(1)
out_dir = os.path.join(args.root, 'out')
if not os.path.isdir(out_dir):
os.makedirs(out_dir)
GenerateXcodeProject(gn_path, args.root, out_dir, settings)
GenerateGnBuildRules(gn_path, args.root, out_dir, settings)
if __name__ == '__main__':
sys.exit(Main(sys.argv[1:]))