ios: Fix Xcode project buildgen.

Change-Id: Ib9701a2c7c7ebc55ba69e1e6fa9cde59d3e4c557
Reviewed-on: https://chromium-review.googlesource.com/c/crashpad/crashpad/+/2378400
Commit-Queue: Justin Cohen <justincohen@chromium.org>
Reviewed-by: Mark Mentovai <mark@chromium.org>
This commit is contained in:
Justin Cohen 2020-08-27 10:14:38 -04:00 committed by Commit Bot
parent 59e8120e7a
commit 71695216f9

View File

@ -29,6 +29,7 @@ import filecmp
import json import json
import hashlib import hashlib
import os import os
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
@ -37,239 +38,244 @@ import tempfile
class XcodeProject(object): class XcodeProject(object):
def __init__(self, objects, counter=0): def __init__(self, objects, counter = 0):
self.objects = objects self.objects = objects
self.counter = 0 self.counter = 0
def AddObject(self, parent_name, obj): def AddObject(self, parent_name, obj):
while True: while True:
self.counter += 1 self.counter += 1
str_id = "%s %s %d" % (parent_name, obj['isa'], self.counter) str_id = "%s %s %d" % (parent_name, obj['isa'], self.counter)
new_id = hashlib.sha1( new_id = hashlib.sha1(str_id.encode("utf-8")).hexdigest()[:24].upper()
str_id.encode("utf-8")).hexdigest()[:24].upper()
# Make sure ID is unique. It's possible there could be an id # Make sure ID is unique. It's possible there could be an id conflict
# conflict since this is run after GN runs. # since this is run after GN runs.
if new_id not in self.objects: if new_id not in self.objects:
self.objects[new_id] = obj self.objects[new_id] = obj
return new_id return new_id
def check_output(command): def check_output(command):
"""Wrapper around subprocess.check_output that decode output as utf-8.""" """Wrapper around subprocess.check_output that decode output as utf-8."""
return subprocess.check_output(command).decode('utf-8') return subprocess.check_output(command).decode('utf-8')
def CopyFileIfChanged(source_path, target_path): def CopyFileIfChanged(source_path, target_path):
"""Copy |source_path| to |target_path| if different.""" """Copy |source_path| to |target_path| if different."""
target_dir = os.path.dirname(target_path) target_dir = os.path.dirname(target_path)
if not os.path.isdir(target_dir): if not os.path.isdir(target_dir):
os.makedirs(target_dir) os.makedirs(target_dir)
if not os.path.exists(target_path) or \ if not os.path.exists(target_path) or \
not filecmp.cmp(source_path, target_path): not filecmp.cmp(source_path, target_path):
shutil.copyfile(source_path, target_path) shutil.copyfile(source_path, target_path)
def CopyTreeIfChanged(source, target): def CopyTreeIfChanged(source, target):
"""Copy |source| to |target| recursively; files are copied iff changed.""" """Copy |source| to |target| recursively; files are copied iff changed."""
if os.path.isfile(source): if os.path.isfile(source):
return CopyFileIfChanged(source, target) return CopyFileIfChanged(source, target)
if not os.path.isdir(target): if not os.path.isdir(target):
os.makedirs(target) os.makedirs(target)
for name in os.listdir(source): for name in os.listdir(source):
CopyTreeIfChanged(os.path.join(source, name), CopyTreeIfChanged(
os.path.join(target, name)) os.path.join(source, name),
os.path.join(target, name))
def LoadXcodeProjectAsJSON(project_dir): def LoadXcodeProjectAsJSON(project_dir):
"""Return Xcode project at |path| as a JSON string.""" """Return Xcode project at |path| as a JSON string."""
return check_output([ return check_output([
'plutil', '-convert', 'json', '-o', '-', 'plutil', '-convert', 'json', '-o', '-',
os.path.join(project_dir, 'project.pbxproj') os.path.join(project_dir, 'project.pbxproj')])
])
def WriteXcodeProject(output_path, json_string): def WriteXcodeProject(output_path, json_string):
"""Save Xcode project to |output_path| as XML.""" """Save Xcode project to |output_path| as XML."""
with tempfile.NamedTemporaryFile() as temp_file: with tempfile.NamedTemporaryFile() as temp_file:
temp_file.write(json_string.encode("utf-8")) temp_file.write(json_string.encode("utf-8"))
temp_file.flush() temp_file.flush()
subprocess.check_call(['plutil', '-convert', 'xml1', temp_file.name]) subprocess.check_call(['plutil', '-convert', 'xml1', temp_file.name])
CopyFileIfChanged(temp_file.name, CopyFileIfChanged(
os.path.join(output_path, 'project.pbxproj')) temp_file.name,
os.path.join(output_path, 'project.pbxproj'))
def UpdateXcodeProject(project_dir, configurations, root_dir): def UpdateXcodeProject(project_dir, configurations, root_dir):
"""Update inplace Xcode project to support multiple configurations. """Update inplace Xcode project to support multiple configurations.
Args: Args:
project_dir: path to the input Xcode project project_dir: path to the input Xcode project
configurations: list of string corresponding to the configurations that configurations: list of string corresponding to the configurations that
need to be supported by the tweaked Xcode projects, must contains at need to be supported by the tweaked Xcode projects, must contains at
least one value. least one value.
root_dir: path to the root directory used to find markdown files root_dir: path to the root directory used to find markdown files
""" """
json_data = json.loads(LoadXcodeProjectAsJSON(project_dir)) json_data = json.loads(LoadXcodeProjectAsJSON(project_dir))
project = XcodeProject(json_data['objects']) project = XcodeProject(json_data['objects'])
objects_to_remove = [] objects_to_remove = []
for value in list(project.objects.values()): for value in list(project.objects.values()):
isa = value['isa'] isa = value['isa']
# Teach build shell script to look for the configuration and platform. # Teach build shell script to look for the configuration and platform.
if isa == 'PBXShellScriptBuildPhase': if isa == 'PBXShellScriptBuildPhase':
value['shellScript'] = value['shellScript'].replace( shell_path = value['shellPath']
'ninja -C .', if shell_path.endswith('/sh'):
'ninja -C "../${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}"') value['shellScript'] = value['shellScript'].replace(
'ninja -C .',
'ninja -C "../${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}"')
elif re.search('[ /]python[23]?$', shell_path):
value['shellScript'] = value['shellScript'].replace(
'ninja_params = [ \'-C\', \'.\' ]',
'ninja_params = [ \'-C\', \'../\' + os.environ[\'CONFIGURATION\']'
' + os.environ[\'EFFECTIVE_PLATFORM_NAME\'] ]')
# Add new configuration, using the first one as default. # Add new configuration, using the first one as default.
if isa == 'XCConfigurationList': if isa == 'XCConfigurationList':
value['defaultConfigurationName'] = configurations[0] value['defaultConfigurationName'] = configurations[0]
objects_to_remove.extend(value['buildConfigurations']) objects_to_remove.extend(value['buildConfigurations'])
build_config_template = project.objects[value['buildConfigurations'] build_config_template = project.objects[value['buildConfigurations'][0]]
[0]] build_config_template['buildSettings']['CONFIGURATION_BUILD_DIR'] = \
build_config_template['buildSettings']['CONFIGURATION_BUILD_DIR'] =\ '$(PROJECT_DIR)/../$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)'
'$(PROJECT_DIR)/../$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)'
value['buildConfigurations'] = [] value['buildConfigurations'] = []
for configuration in configurations: for configuration in configurations:
new_build_config = copy.copy(build_config_template) new_build_config = copy.copy(build_config_template)
new_build_config['name'] = configuration new_build_config['name'] = configuration
value['buildConfigurations'].append( value['buildConfigurations'].append(
project.AddObject('products', new_build_config)) project.AddObject('products', new_build_config))
for object_id in objects_to_remove: for object_id in objects_to_remove:
del project.objects[object_id] del project.objects[object_id]
source = GetOrCreateRootGroup(project, json_data['rootObject'], 'Source') source = GetOrCreateRootGroup(project, json_data['rootObject'], 'Source')
AddMarkdownToProject(project, root_dir, source) AddMarkdownToProject(project, root_dir, source)
SortFileReferencesByName(project, source) SortFileReferencesByName(project, source)
objects = collections.OrderedDict(sorted(project.objects.items())) objects = collections.OrderedDict(sorted(project.objects.items()))
WriteXcodeProject(project_dir, json.dumps(json_data)) WriteXcodeProject(project_dir, json.dumps(json_data))
def CreateGroup(project, parent_group, group_name, path=None): def CreateGroup(project, parent_group, group_name, path=None):
group_object = { group_object = {
'children': [], 'children': [],
'isa': 'PBXGroup', 'isa': 'PBXGroup',
'name': group_name, 'name': group_name,
'sourceTree': '<group>', 'sourceTree': '<group>',
} }
if path is not None: if path is not None:
group_object['path'] = path group_object['path'] = path
parent_group_name = parent_group.get('name', '') parent_group_name = parent_group.get('name', '')
group_object_key = project.AddObject(parent_group_name, group_object) group_object_key = project.AddObject(parent_group_name, group_object)
parent_group['children'].append(group_object_key) parent_group['children'].append(group_object_key)
return group_object return group_object
def GetOrCreateRootGroup(project, root_object, group_name): def GetOrCreateRootGroup(project, root_object, group_name):
main_group = project.objects[project.objects[root_object]['mainGroup']] main_group = project.objects[project.objects[root_object]['mainGroup']]
for child_key in main_group['children']: for child_key in main_group['children']:
child = project.objects[child_key] child = project.objects[child_key]
if child['name'] == group_name: if child['name'] == group_name:
return child return child
return CreateGroup(project, main_group, group_name, path='../..') return CreateGroup(project, main_group, group_name, path='../..')
class ObjectKey(object): class ObjectKey(object):
"""Wrapper around PBXFileReference and PBXGroup for sorting.
A PBXGroup represents a "directory" containing a list of files in an """Wrapper around PBXFileReference and PBXGroup for sorting.
Xcode project; it can contain references to a list of directories or
files.
A PBXFileReference represents a "file". A PBXGroup represents a "directory" containing a list of files in an
Xcode project; it can contain references to a list of directories or
files.
The type is stored in the object "isa" property as a string. Since we A PBXFileReference represents a "file".
want to sort all directories before all files, the < and > operators
are defined so that if "isa" is different, they are sorted in the
reverse of alphabetic ordering, otherwise the name (or path) property
is checked and compared in alphabetic order.
"""
def __init__(self, obj): The type is stored in the object "isa" property as a string. Since we
self.isa = obj['isa'] want to sort all directories before all files, the < and > operators
if 'name' in obj: are defined so that if "isa" is different, they are sorted in the
self.name = obj['name'] reverse of alphabetic ordering, otherwise the name (or path) property
else: is checked and compared in alphabetic order.
self.name = obj['path'] """
def __lt__(self, other): def __init__(self, obj):
if self.isa != other.isa: self.isa = obj['isa']
return self.isa > other.isa if 'name' in obj:
return self.name < other.name self.name = obj['name']
else:
self.name = obj['path']
def __gt__(self, other): def __lt__(self, other):
if self.isa != other.isa: if self.isa != other.isa:
return self.isa < other.isa return self.isa > other.isa
return self.name > other.name return self.name < other.name
def __eq__(self, other): def __gt__(self, other):
return self.isa == other.isa and self.name == other.name if self.isa != other.isa:
return self.isa < other.isa
return self.name > other.name
def __eq__(self, other):
return self.isa == other.isa and self.name == other.name
def SortFileReferencesByName(project, group_object): def SortFileReferencesByName(project, group_object):
SortFileReferencesByNameWithSortKey( SortFileReferencesByNameWithSortKey(
project, group_object, lambda ref: ObjectKey(project.objects[ref])) project, group_object, lambda ref: ObjectKey(project.objects[ref]))
def SortFileReferencesByNameWithSortKey(project, group_object, sort_key): def SortFileReferencesByNameWithSortKey(project, group_object, sort_key):
group_object['children'].sort(key=sort_key) group_object['children'].sort(key=sort_key)
for key in group_object['children']: for key in group_object['children']:
child = project.objects[key] child = project.objects[key]
if child['isa'] == 'PBXGroup': if child['isa'] == 'PBXGroup':
SortFileReferencesByNameWithSortKey(project, child, sort_key) SortFileReferencesByNameWithSortKey(project, child, sort_key)
def AddMarkdownToProject(project, root_dir, group_object): def AddMarkdownToProject(project, root_dir, group_object):
list_files_cmd = ['git', '-C', root_dir, 'ls-files', '*.md'] list_files_cmd = ['git', '-C', root_dir, 'ls-files', '*.md']
paths = check_output(list_files_cmd).splitlines() paths = check_output(list_files_cmd).splitlines()
ios_internal_dir = os.path.join(root_dir, 'ios_internal') ios_internal_dir = os.path.join(root_dir, 'ios_internal')
if os.path.exists(ios_internal_dir): if os.path.exists(ios_internal_dir):
list_files_cmd = ['git', '-C', ios_internal_dir, 'ls-files', '*.md'] list_files_cmd = ['git', '-C', ios_internal_dir, 'ls-files', '*.md']
ios_paths = check_output(list_files_cmd).splitlines() ios_paths = check_output(list_files_cmd).splitlines()
paths.extend([os.path.join("ios_internal", path) for path in ios_paths]) paths.extend([os.path.join("ios_internal", path) for path in ios_paths])
for path in paths: for path in paths:
new_markdown_entry = { new_markdown_entry = {
"fileEncoding": "4", "fileEncoding": "4",
"isa": "PBXFileReference", "isa": "PBXFileReference",
"lastKnownFileType": "net.daringfireball.markdown", "lastKnownFileType": "net.daringfireball.markdown",
"name": os.path.basename(path), "name": os.path.basename(path),
"path": path, "path": path,
"sourceTree": "<group>" "sourceTree": "<group>"
} }
new_markdown_entry_id = project.AddObject('sources', new_markdown_entry) new_markdown_entry_id = project.AddObject('sources', new_markdown_entry)
folder = GetFolderForPath(project, group_object, os.path.dirname(path)) folder = GetFolderForPath(project, group_object, os.path.dirname(path))
folder['children'].append(new_markdown_entry_id) folder['children'].append(new_markdown_entry_id)
def GetFolderForPath(project, group_object, path): def GetFolderForPath(project, group_object, path):
objects = project.objects objects = project.objects
if not path: if not path:
return group_object
for folder in path.split('/'):
children = group_object['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_root = CreateGroup(project, group_object, folder)
group_object = new_root
return group_object return group_object
for folder in path.split('/'):
children = group_object['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_root = CreateGroup(project, group_object, folder)
group_object = new_root
return group_object
def ConvertGnXcodeProject(root_dir, input_dir, output_dir, configurations): def ConvertGnXcodeProject(root_dir, input_dir, output_dir, configurations):
'''Tweak the Xcode project generated by gn to support multiple '''Tweak the Xcode project generated by gn to support multiple configurations.
configurations.
The Xcode projects generated by "gn gen --ide" only supports a single The Xcode projects generated by "gn gen --ide" only supports a single
platform and configuration (as the platform and configuration are set platform and configuration (as the platform and configuration are set
@ -285,76 +291,71 @@ def ConvertGnXcodeProject(root_dir, input_dir, output_dir, configurations):
least one value. least one value.
''' '''
# Update the project (supports legacy name "products.xcodeproj" or the new # Update the project (supports legacy name "products.xcodeproj" or the new
# project name "all.xcodeproj"). # project name "all.xcodeproj").
for project_name in ('all.xcodeproj', 'products.xcodeproj'): for project_name in ('all.xcodeproj', 'products.xcodeproj'):
if os.path.exists(os.path.join(input_dir, project_name)): if os.path.exists(os.path.join(input_dir, project_name)):
UpdateXcodeProject(os.path.join(input_dir, project_name), UpdateXcodeProject(
configurations, root_dir) os.path.join(input_dir, project_name),
configurations, root_dir)
CopyTreeIfChanged(os.path.join(input_dir, project_name), CopyTreeIfChanged(os.path.join(input_dir, project_name),
os.path.join(output_dir, project_name)) os.path.join(output_dir, project_name))
else:
shutil.rmtree(os.path.join(output_dir, project_name),
ignore_errors=True)
# Copy all.xcworkspace if it exists (will be removed in a future gn version).
workspace_name = 'all.xcworkspace'
if os.path.exists(os.path.join(input_dir, workspace_name)):
CopyTreeIfChanged(os.path.join(input_dir, workspace_name),
os.path.join(output_dir, workspace_name))
else: else:
shutil.rmtree(os.path.join(output_dir, workspace_name), shutil.rmtree(os.path.join(output_dir, project_name), ignore_errors=True)
ignore_errors=True)
# Copy all.xcworkspace if it exists (will be removed in a future gn version).
workspace_name = 'all.xcworkspace'
if os.path.exists(os.path.join(input_dir, workspace_name)):
CopyTreeIfChanged(os.path.join(input_dir, workspace_name),
os.path.join(output_dir, workspace_name))
else:
shutil.rmtree(os.path.join(output_dir, workspace_name), ignore_errors=True)
def Main(args): def Main(args):
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Convert GN Xcode projects for iOS.') description='Convert GN Xcode projects for iOS.')
parser.add_argument( parser.add_argument(
'input', help='directory containing [product|all] Xcode projects.') 'input',
parser.add_argument( help='directory containing [product|all] Xcode projects.')
'output', help='directory where to generate the iOS configuration.') parser.add_argument(
parser.add_argument('--add-config', 'output',
dest='configurations', help='directory where to generate the iOS configuration.')
default=[], parser.add_argument(
action='append', '--add-config', dest='configurations', default=[], action='append',
help='configuration to add to the Xcode project') help='configuration to add to the Xcode project')
parser.add_argument('--root', parser.add_argument(
type=os.path.abspath, '--root', type=os.path.abspath, required=True,
required=True, help='root directory of the project')
help='root directory of the project') args = parser.parse_args(args)
args = parser.parse_args(args)
if not os.path.isdir(args.input): if not os.path.isdir(args.input):
sys.stderr.write('Input directory does not exists.\n') sys.stderr.write('Input directory does not exists.\n')
return 1 return 1
# Depending on the version of "gn", there should be either one project file # Depending on the version of "gn", there should be either one project file
# named "all.xcodeproj" or a project file named "products.xcodeproj" and a # named "all.xcodeproj" or a project file named "products.xcodeproj" and a
# workspace named "all.xcworkspace". # workspace named "all.xcworkspace".
required_files_sets = [ required_files_sets = [
set(("all.xcodeproj",)), set(("all.xcodeproj",)),
set(("products.xcodeproj", "all.xcworkspace")), set(("products.xcodeproj", "all.xcworkspace")),
] ]
for required_files in required_files_sets: for required_files in required_files_sets:
if required_files.issubset(os.listdir(args.input)): if required_files.issubset(os.listdir(args.input)):
break break
else: else:
sys.stderr.write( sys.stderr.write(
'Input directory does not contain all necessary Xcode projects.\n') 'Input directory does not contain all necessary Xcode projects.\n')
return 1 return 1
if not args.configurations: if not args.configurations:
sys.stderr.write( sys.stderr.write('At least one configuration required, see --add-config.\n')
'At least one configuration required, see --add-config.\n') return 1
return 1
ConvertGnXcodeProject(args.root, args.input, args.output,
args.configurations)
ConvertGnXcodeProject(args.root, args.input, args.output, args.configurations)
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(Main(sys.argv[1:])) sys.exit(Main(sys.argv[1:]))