| #!/usr/bin/python | |
| # Copyright 2016 The Tulsi 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. | |
| """Bridge between Xcode and Bazel for the "build" action. | |
| NOTE: This script must be executed in the same directory as the Xcode project's | |
| main group in order to generate correct debug symbols. | |
| """ | |
| import atexit | |
| import collections | |
| import errno | |
| import fcntl | |
| import io | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import signal | |
| import StringIO | |
| import subprocess | |
| import sys | |
| import textwrap | |
| import time | |
| import zipfile | |
| import bazel_build_events | |
| import bazel_options | |
| from execroot_path import BAZEL_EXECUTION_ROOT | |
| import tulsi_logging | |
| # List of frameworks that Xcode injects into test host targets that should be | |
| # re-signed when running the tests on devices. | |
| XCODE_INJECTED_FRAMEWORKS = [ | |
| 'IDEBundleInjection', | |
| 'XCTAutomationSupport', | |
| 'XCTest', | |
| ] | |
| _logger = tulsi_logging.Logger() | |
| def _PrintXcodeWarning(msg): | |
| sys.stdout.write(':: warning: %s\n' % msg) | |
| sys.stdout.flush() | |
| def _PrintXcodeError(msg): | |
| sys.stderr.write(':: error: %s\n' % msg) | |
| sys.stderr.flush() | |
| CLEANUP_BEP_FILE_AT_EXIT = False | |
| # Function to be called atexit to clean up the BEP file if one is present. | |
| # This is especially useful in cases of abnormal termination (such as what | |
| # happens when Xcode is killed). | |
| def _BEPFileExitCleanup(bep_file_path): | |
| if not CLEANUP_BEP_FILE_AT_EXIT: | |
| return | |
| try: | |
| os.remove(bep_file_path) | |
| except OSError as e: | |
| _PrintXcodeWarning('Failed to remove BEP file from %s. Error: %s' % | |
| (bep_file_path, e.strerror)) | |
| def _InterruptHandler(signum, frame): | |
| """Gracefully exit on SIGINT.""" | |
| del signum, frame # Unused. | |
| sys.stdout.write('Caught interrupt signal. Exiting...\n') | |
| sys.exit(0) | |
| class Timer(object): | |
| """Simple profiler.""" | |
| def __init__(self, action_name, action_id): | |
| """Creates a new Timer object. | |
| Args: | |
| action_name: A human-readable action name, shown in the build log. | |
| action_id: A machine-readable action identifier, can be used for metrics. | |
| Returns: | |
| A Timer instance. | |
| """ | |
| self.action_name = action_name | |
| self.action_id = action_id | |
| self._start = None | |
| def Start(self): | |
| self._start = time.time() | |
| return self | |
| def End(self): | |
| end = time.time() | |
| seconds = end - self._start | |
| _logger.log_action(self.action_name, self.action_id, seconds) | |
| # Function to be called atexit to release the file lock on script termination. | |
| def _LockFileExitCleanup(lock_file_handle): | |
| lock_file_handle.close() | |
| def _LockFileAcquire(lock_path): | |
| """Force script to wait on global file lock to serialize build target actions. | |
| Args: | |
| lock_path: Path to the lock file. | |
| """ | |
| sys.stdout.write('Queuing Tulsi build...\n') | |
| sys.stdout.flush() | |
| locktimer = Timer('Acquiring %s' % lock_path, 'tulsi_build_lock').Start() | |
| # TODO(b/69414272): See if we can improve this for multiple WORKSPACEs. | |
| lockfile = open(lock_path, 'w') | |
| # Register "fclose(...)" as early as possible, before acquiring lock. | |
| atexit.register(_LockFileExitCleanup, lockfile) | |
| while True: | |
| try: | |
| fcntl.lockf(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) | |
| break | |
| except IOError as err: | |
| if err.errno != errno.EAGAIN: | |
| raise | |
| else: | |
| time.sleep(0.1) | |
| locktimer.End() | |
| class CodesignBundleAttributes(object): | |
| """Wrapper class for codesigning attributes of a signed bundle.""" | |
| # List of codesigning attributes that this script requires. | |
| _ATTRIBUTES = ['Authority', 'Identifier', 'TeamIdentifier'] | |
| def __init__(self, codesign_output): | |
| self.attributes = {} | |
| pending_attributes = list(self._ATTRIBUTES) | |
| for line in codesign_output.split('\n'): | |
| if not pending_attributes: | |
| break | |
| for attribute in pending_attributes: | |
| if line.startswith(attribute): | |
| value = line[len(attribute) + 1:] | |
| self.attributes[attribute] = value | |
| pending_attributes.remove(attribute) | |
| break | |
| for attribute in self._ATTRIBUTES: | |
| if attribute not in self.attributes: | |
| _PrintXcodeError( | |
| 'Failed to extract %s from %s.\n' % (attribute, codesign_output)) | |
| def Get(self, attribute): | |
| """Returns the value for the given attribute, or None if it wasn't found.""" | |
| value = self.attributes.get(attribute) | |
| if attribute not in self._ATTRIBUTES: | |
| _PrintXcodeError( | |
| 'Attribute %s not declared to be parsed. ' % attribute + | |
| 'Available attributes are %s.\n' % self._ATTRIBUTES) | |
| return value | |
| class _OptionsParser(object): | |
| """Handles parsing script options.""" | |
| # Key for options that should be applied to all build configurations. | |
| ALL_CONFIGS = '__all__' | |
| # The build configurations handled by this parser. | |
| KNOWN_CONFIGS = ['Debug', 'Release', 'Fastbuild'] | |
| def __init__(self, sdk_version, platform_name, arch, main_group_path): | |
| self.targets = [] | |
| self.startup_options = collections.defaultdict(list) | |
| self.build_options = collections.defaultdict( | |
| list, | |
| { | |
| _OptionsParser.ALL_CONFIGS: [ | |
| '--verbose_failures', | |
| '--announce_rc', | |
| ], | |
| 'Debug': [ | |
| '--compilation_mode=dbg', | |
| ], | |
| 'Release': [ | |
| '--compilation_mode=opt', | |
| '--strip=always', | |
| ], | |
| 'Fastbuild': [ | |
| '--compilation_mode=fastbuild', | |
| ], | |
| }) | |
| # Options specific to debugger integration in Xcode. | |
| xcode_version_major = int(os.environ['XCODE_VERSION_MAJOR']) | |
| if xcode_version_major < 800: | |
| xcode_lldb_options = [ | |
| '--copt=-Xclang', '--copt=-fdebug-compilation-dir', | |
| '--copt=-Xclang', '--copt=%s' % main_group_path, | |
| '--objccopt=-Xclang', '--objccopt=-fdebug-compilation-dir', | |
| '--objccopt=-Xclang', '--objccopt=%s' % main_group_path, | |
| ] | |
| self.build_options['Debug'].extend(xcode_lldb_options) | |
| self.build_options['Release'].extend(xcode_lldb_options) | |
| self.sdk_version = sdk_version | |
| self.platform_name = platform_name | |
| if self.platform_name.startswith('watch'): | |
| config_platform = 'watchos' | |
| elif self.platform_name.startswith('iphone'): | |
| config_platform = 'ios' | |
| elif self.platform_name.startswith('macos'): | |
| config_platform = 'darwin' | |
| elif self.platform_name.startswith('appletv'): | |
| config_platform = 'tvos' | |
| else: | |
| self._WarnUnknownPlatform() | |
| config_platform = 'ios' | |
| self.build_options[_OptionsParser.ALL_CONFIGS].append( | |
| '--config=%s_%s' % (config_platform, arch)) | |
| self.verbose = 0 | |
| self.install_generated_artifacts = False | |
| self.bazel_bin_path = 'bazel-bin' | |
| self.bazel_executable = None | |
| @staticmethod | |
| def _UsageMessage(): | |
| """Returns a usage message string.""" | |
| usage = textwrap.dedent("""\ | |
| Usage: %s <target> [<target2> ...] --bazel <bazel_binary_path> [options] | |
| Where options are: | |
| --verbose [-v] | |
| Increments the verbosity of the script by one level. This argument | |
| may be provided multiple times to enable additional output levels. | |
| --bazel_startup_options <option1> [<option2> ...] -- | |
| Provides one or more Bazel startup options. | |
| --bazel_options <option1> [<option2> ...] -- | |
| Provides one or more Bazel build options. | |
| --bazel_bin_path <path> | |
| Path at which Bazel-generated artifacts may be retrieved. | |
| """ % sys.argv[0]) | |
| usage += '\n' + textwrap.fill( | |
| 'Note that the --bazel_startup_options and --bazel_options options may ' | |
| 'include an optional configuration specifier in brackets to limit ' | |
| 'their contents to a given build configuration. Options provided with ' | |
| 'no configuration filter will apply to all configurations in addition ' | |
| 'to any configuration-specific options.', 120) | |
| usage += '\n' + textwrap.fill( | |
| 'E.g., --bazel_options common -- --bazel_options[Release] release -- ' | |
| 'would result in "bazel build common release" in the "Release" ' | |
| 'configuration and "bazel build common" in all other configurations.', | |
| 120) | |
| return usage | |
| def ParseOptions(self, args): | |
| """Parses arguments, returning (message, exit_code).""" | |
| bazel_executable_index = args.index('--bazel') | |
| self.targets = args[:bazel_executable_index] | |
| if not self.targets or len(args) < bazel_executable_index + 2: | |
| return (self._UsageMessage(), 10) | |
| self.bazel_executable = args[bazel_executable_index + 1] | |
| return self._ParseVariableOptions(args[bazel_executable_index + 2:]) | |
| def GetStartupOptions(self, config): | |
| """Returns the full set of startup options for the given config.""" | |
| return self._GetOptions(self.startup_options, config) | |
| def GetBuildOptions(self, config): | |
| """Returns the full set of build options for the given config.""" | |
| options = self._GetOptions(self.build_options, config) | |
| version_string = self._GetXcodeVersionString() | |
| if version_string: | |
| self._AddDefaultOption(options, '--xcode_version', version_string) | |
| if self.sdk_version: | |
| if self.platform_name.startswith('watch'): | |
| self._AddDefaultOption(options, | |
| '--watchos_sdk_version', | |
| self.sdk_version) | |
| elif self.platform_name.startswith('iphone'): | |
| self._AddDefaultOption(options, '--ios_sdk_version', self.sdk_version) | |
| elif self.platform_name.startswith('macos'): | |
| self._AddDefaultOption(options, '--macos_sdk_version', self.sdk_version) | |
| elif self.platform_name.startswith('appletv'): | |
| self._AddDefaultOption(options, '--tvos_sdk_version', self.sdk_version) | |
| else: | |
| self._WarnUnknownPlatform() | |
| self._AddDefaultOption(options, '--ios_sdk_version', self.sdk_version) | |
| return options | |
| @staticmethod | |
| def _AddDefaultOption(option_list, option, default_value): | |
| matching_options = [opt for opt in option_list if opt.startswith(option)] | |
| if matching_options: | |
| return option_list | |
| option_list.append('%s=%s' % (option, default_value)) | |
| return option_list | |
| @staticmethod | |
| def _GetOptions(option_set, config): | |
| """Returns a flattened list from options_set for the given config.""" | |
| options = list(option_set[_OptionsParser.ALL_CONFIGS]) | |
| if config != _OptionsParser.ALL_CONFIGS: | |
| options.extend(option_set[config]) | |
| return options | |
| def _WarnUnknownPlatform(self): | |
| sys.stdout.write('Warning: unknown platform "%s" will be treated as ' | |
| 'iOS\n' % self.platform_name) | |
| sys.stdout.flush() | |
| def _ParseVariableOptions(self, args): | |
| """Parses flag-based args, returning (message, exit_code).""" | |
| verbose_re = re.compile('-(v+)$') | |
| while args: | |
| arg = args[0] | |
| args = args[1:] | |
| if arg == '--install_generated_artifacts': | |
| self.install_generated_artifacts = True | |
| elif arg.startswith('--bazel_startup_options'): | |
| config = self._ParseConfigFilter(arg) | |
| args, items, terminated = self._ParseDoubleDashDelimitedItems(args) | |
| if not terminated: | |
| return ('Missing "--" terminator while parsing %s' % arg, 2) | |
| duplicates = self._FindDuplicateOptions(self.startup_options, | |
| config, | |
| items) | |
| if duplicates: | |
| return ( | |
| '%s items conflict with common options: %s' % ( | |
| arg, ','.join(duplicates)), | |
| 2) | |
| self.startup_options[config].extend(items) | |
| elif arg.startswith('--bazel_options'): | |
| config = self._ParseConfigFilter(arg) | |
| args, items, terminated = self._ParseDoubleDashDelimitedItems(args) | |
| if not terminated: | |
| return ('Missing "--" terminator while parsing %s' % arg, 2) | |
| duplicates = self._FindDuplicateOptions(self.build_options, | |
| config, | |
| items) | |
| if duplicates: | |
| return ( | |
| '%s items conflict with common options: %s' % ( | |
| arg, ','.join(duplicates)), | |
| 2) | |
| self.build_options[config].extend(items) | |
| elif arg == '--bazel_bin_path': | |
| if not args: | |
| return ('Missing required parameter for %s' % arg, 2) | |
| self.bazel_bin_path = args[0] | |
| args = args[1:] | |
| elif arg == '--verbose': | |
| self.verbose += 1 | |
| else: | |
| match = verbose_re.match(arg) | |
| if match: | |
| self.verbose += len(match.group(1)) | |
| else: | |
| return ('Unknown option "%s"\n%s' % (arg, self._UsageMessage()), 1) | |
| return (None, 0) | |
| @staticmethod | |
| def _ParseConfigFilter(arg): | |
| match = re.search(r'\[([^\]]+)\]', arg) | |
| if not match: | |
| return _OptionsParser.ALL_CONFIGS | |
| return match.group(1) | |
| @staticmethod | |
| def _ConsumeArgumentForParam(param, args): | |
| if not args: | |
| return (None, 'Missing required parameter for "%s" option' % param) | |
| val = args[0] | |
| return (args[1:], val) | |
| @staticmethod | |
| def _ParseDoubleDashDelimitedItems(args): | |
| """Consumes options until -- is found.""" | |
| options = [] | |
| terminator_found = False | |
| opts = args | |
| while opts: | |
| opt = opts[0] | |
| opts = opts[1:] | |
| if opt == '--': | |
| terminator_found = True | |
| break | |
| options.append(opt) | |
| return opts, options, terminator_found | |
| @staticmethod | |
| def _FindDuplicateOptions(options_dict, config, new_options): | |
| """Returns a list of options appearing in both given option lists.""" | |
| allowed_duplicates = [ | |
| '--copt', | |
| '--config', | |
| '--define', | |
| '--objccopt', | |
| ] | |
| def ExtractOptionNames(opts): | |
| names = set() | |
| for opt in opts: | |
| split_opt = opt.split('=', 1) | |
| if split_opt[0] not in allowed_duplicates: | |
| names.add(split_opt[0]) | |
| return names | |
| current_set = ExtractOptionNames(options_dict[config]) | |
| new_set = ExtractOptionNames(new_options) | |
| conflicts = current_set.intersection(new_set) | |
| if config != _OptionsParser.ALL_CONFIGS: | |
| current_set = ExtractOptionNames(options_dict[_OptionsParser.ALL_CONFIGS]) | |
| conflicts = conflicts.union(current_set.intersection(new_set)) | |
| return conflicts | |
| @staticmethod | |
| def _GetXcodeVersionString(): | |
| """Returns Xcode version info from the environment as a string.""" | |
| reported_version = os.environ['XCODE_VERSION_ACTUAL'] | |
| match = re.match(r'(\d{2})(\d)(\d)$', reported_version) | |
| if not match: | |
| sys.stdout.write('Warning: Failed to extract Xcode version from %s\n' % ( | |
| reported_version)) | |
| sys.stdout.flush() | |
| return None | |
| major_version = int(match.group(1)) | |
| minor_version = int(match.group(2)) | |
| fix_version = int(match.group(3)) | |
| fix_version_string = '' | |
| if fix_version: | |
| fix_version_string = '.%d' % fix_version | |
| return '%d.%d%s' % (major_version, minor_version, fix_version_string) | |
| class BazelBuildBridge(object): | |
| """Handles invoking Bazel and unpacking generated binaries.""" | |
| BUILD_EVENTS_FILE = 'build_events.json' | |
| SPOTLIGHT_CHECK_ENVVAR = 'TULSI_BUILD_WITHOUT_SPOTLIGHT_AT_MY_OWN_RISK' | |
| def __init__(self): | |
| self.verbose = 0 | |
| self.build_path = None | |
| self.bazel_bin_path = None | |
| self.codesign_attributes = {} | |
| self.codesigning_folder_path = os.environ['CODESIGNING_FOLDER_PATH'] | |
| self.xcode_action = os.environ['ACTION'] # The Xcode build action. | |
| # When invoked as an external build system script, Xcode will set ACTION to | |
| # an empty string. | |
| if not self.xcode_action: | |
| self.xcode_action = 'build' | |
| self.build_without_spotlight = os.environ.get( | |
| BazelBuildBridge.SPOTLIGHT_CHECK_ENVVAR, 'NO') == 'YES' | |
| self.generate_dsym = (os.environ.get('TULSI_ALL_DSYM', 'NO') == 'YES' or | |
| os.environ.get('TULSI_MUST_USE_DSYM', 'NO') == 'YES') | |
| self.use_debug_prefix_map = os.environ.get('TULSI_DEBUG_PREFIX_MAP', | |
| 'NO') == 'YES' | |
| self.extra_remap_path = os.environ.get('TULSI_EXTRA_REMAP_PATH', '') | |
| # Target architecture. Must be defined for correct setting of | |
| # the --config flag | |
| self.arch = os.environ.get('CURRENT_ARCH') | |
| if not self.arch: | |
| _PrintXcodeError('Tulsi requires env variable CURRENT_ARCH to be ' | |
| 'set. Please file a bug against Tulsi.') | |
| sys.exit(1) | |
| # Path into which generated artifacts should be copied. | |
| self.built_products_dir = os.environ['BUILT_PRODUCTS_DIR'] | |
| # Whether or not code coverage information should be generated. | |
| self.code_coverage_enabled = ( | |
| os.environ.get('CLANG_COVERAGE_MAPPING') == 'YES') | |
| # Path where Xcode expects generated sources to be placed. | |
| self.derived_sources_folder_path = os.environ.get('DERIVED_SOURCES_DIR') | |
| # Full name of the target artifact (e.g., "MyApp.app" or "Test.xctest"). | |
| self.full_product_name = os.environ['FULL_PRODUCT_NAME'] | |
| # Target SDK version. | |
| self.sdk_version = os.environ.get('SDK_VERSION') | |
| # TEST_HOST for unit tests. | |
| self.test_host_binary = os.environ.get('TEST_HOST') | |
| # Whether this target is a test or not. | |
| self.is_test = os.environ.get('WRAPPER_EXTENSION') == 'xctest' | |
| # Target platform. | |
| self.platform_name = os.environ['PLATFORM_NAME'] | |
| # Type of the target artifact. | |
| self.product_type = os.environ['PRODUCT_TYPE'] | |
| # Path to the parent of the xcodeproj bundle. | |
| self.project_dir = os.environ['PROJECT_DIR'] | |
| # Path to the xcodeproj bundle. | |
| self.project_file_path = os.environ['PROJECT_FILE_PATH'] | |
| # Path to the directory containing the WORKSPACE file. | |
| self.workspace_root = os.path.abspath(os.environ['TULSI_WR']) | |
| # Set to the name of the generated bundle for bundle-type targets, None for | |
| # single file targets (like static libraries). | |
| self.wrapper_name = os.environ.get('WRAPPER_NAME') | |
| self.wrapper_suffix = os.environ.get('WRAPPER_SUFFIX', '') | |
| self.xcode_version_major = int(os.environ['XCODE_VERSION_MAJOR']) | |
| self.xcode_version_minor = int(os.environ['XCODE_VERSION_MINOR']) | |
| # Path where Xcode expects the artifacts to be written to. This is not the | |
| # codesigning_path as device vs simulator builds have different signing | |
| # requirements, so Xcode expects different paths to be signed. This is | |
| # mostly apparent on XCUITests where simulator builds set the codesigning | |
| # path to be the .xctest bundle, but for device builds it is actually the | |
| # UI runner app (since it needs to be codesigned to run on the device.) The | |
| # FULL_PRODUCT_NAME variable is a stable path on where to put the expected | |
| # artifacts. For static libraries (objc_library, swift_library), | |
| # FULL_PRODUCT_NAME corresponds to the .a file name, which coincides with | |
| # the expected location for a single artifact output. | |
| # TODO(b/35811023): Check these paths are still valid. | |
| self.artifact_output_path = os.path.join( | |
| os.environ['TARGET_BUILD_DIR'], | |
| os.environ['FULL_PRODUCT_NAME']) | |
| # Path to where Xcode expects the binary to be placed. | |
| self.binary_path = os.path.join( | |
| os.environ['TARGET_BUILD_DIR'], os.environ['EXECUTABLE_PATH']) | |
| self.is_simulator = self.platform_name.endswith('simulator') | |
| # Check to see if code signing actions should be skipped or not. | |
| if self.is_simulator: | |
| self.codesigning_allowed = False | |
| else: | |
| self.codesigning_allowed = os.environ.get('CODE_SIGNING_ALLOWED') == 'YES' | |
| self.post_processor_binary = os.path.join(self.project_file_path, | |
| '.tulsi', | |
| 'Utils', | |
| 'post_processor') | |
| if self.codesigning_allowed: | |
| platform_prefix = 'iOS' | |
| if self.platform_name.startswith('macos'): | |
| platform_prefix = 'macOS' | |
| entitlements_filename = '%sXCTRunner.entitlements' % platform_prefix | |
| self.runner_entitlements_template = os.path.join(self.project_file_path, | |
| '.tulsi', | |
| 'Resources', | |
| entitlements_filename) | |
| self.main_group_path = os.getcwd() | |
| self.bazel_executable = None | |
| def Run(self, args): | |
| """Executes a Bazel build based on the environment and given arguments.""" | |
| if self.xcode_action != 'build': | |
| sys.stderr.write('Xcode action is %s, ignoring.' % self.xcode_action) | |
| return 0 | |
| if not self.build_without_spotlight: | |
| spotlight_status = self._CheckSpotlightStatus() | |
| if spotlight_status: | |
| return spotlight_status | |
| parser = _OptionsParser(self.sdk_version, | |
| self.platform_name, | |
| self.arch, | |
| self.main_group_path) | |
| timer = Timer('Parsing options', 'parsing_options').Start() | |
| message, exit_code = parser.ParseOptions(args[1:]) | |
| timer.End() | |
| if exit_code: | |
| _PrintXcodeError('Option parsing failed: %s' % message) | |
| return exit_code | |
| self.verbose = parser.verbose | |
| self.bazel_bin_path = os.path.abspath(parser.bazel_bin_path) | |
| self.bazel_executable = parser.bazel_executable | |
| # Use -fdebug-prefix-map to have debug symbols match Xcode-visible sources. | |
| # | |
| # NOTE: Use of -fdebug-prefix-map leads to producing binaries that cannot be | |
| # reused across multiple machines by a distributed build system, unless the | |
| # absolute paths to files visible to Xcode match perfectly between all of | |
| # those machines. | |
| # | |
| # For this reason, -fdebug-prefix-map is provided as a default for non- | |
| # distributed purposes. | |
| if self.use_debug_prefix_map: | |
| # Add the debug source maps now that we have bazel_executable. | |
| source_maps = self._ExtractTargetSourceMaps() | |
| prefix_maps = [] | |
| for source_map in source_maps: | |
| prefix_maps.append('--copt=-fdebug-prefix-map=%s=%s' % | |
| source_map) | |
| # Extend our list of build options with maps just prior to building. | |
| parser.build_options[_OptionsParser.ALL_CONFIGS].extend(prefix_maps) | |
| self.build_path = os.path.join(self.bazel_bin_path, | |
| os.environ.get('TULSI_BUILD_PATH', '')) | |
| # Path to the Build Events JSON file uses pid and is removed if the | |
| # build is successful. | |
| filename = '%d_%s' % (os.getpid(), BazelBuildBridge.BUILD_EVENTS_FILE) | |
| self.build_events_file_path = os.path.join( | |
| self.project_file_path, | |
| '.tulsi', | |
| filename) | |
| (command, retval) = self._BuildBazelCommand(parser) | |
| if retval: | |
| return retval | |
| timer = Timer('Running Bazel', 'running_bazel').Start() | |
| exit_code, outputs = self._RunBazelAndPatchOutput(command) | |
| timer.End() | |
| if exit_code: | |
| _PrintXcodeError('Bazel build failed.') | |
| return exit_code | |
| if not os.path.exists(BAZEL_EXECUTION_ROOT): | |
| _PrintXcodeError('No Bazel execution root was found at %s. Debugging ' | |
| 'experience will be compromised. Please report a Tulsi ' | |
| 'bug.' % BAZEL_EXECUTION_ROOT) | |
| return 404 | |
| # This needs to run after `bazel build`, since it depends on the Bazel | |
| # workspace directory | |
| exit_code = self._LinkTulsiWorkspace() | |
| if exit_code: | |
| return exit_code | |
| if parser.install_generated_artifacts: | |
| timer = Timer('Installing artifacts', 'installing_artifacts').Start() | |
| exit_code = self._InstallArtifact(outputs) | |
| timer.End() | |
| if exit_code: | |
| return exit_code | |
| timer = Timer('Installing generated headers', | |
| 'installing_generated_headers').Start() | |
| exit_code = self._InstallGeneratedHeaders(outputs) | |
| timer.End() | |
| if exit_code: | |
| return exit_code | |
| if self.generate_dsym: | |
| timer = Timer('Installing DSYM bundles', 'installing_dsym').Start() | |
| exit_code, dsym_paths = self._InstallDSYMBundles( | |
| self.built_products_dir, outputs) | |
| timer.End() | |
| if exit_code: | |
| return exit_code | |
| for path in dsym_paths: | |
| # Starting with Xcode 9.x, a plist based solution exists for dSYM | |
| # bundles that works with Swift as well as (Obj-)C(++). | |
| if self.xcode_version_major >= 900: | |
| timer = Timer('Adding remappings as plists to dSYM', | |
| 'plist_dsym').Start() | |
| exit_code = self._PlistdSYMPaths(path) | |
| timer.End() | |
| if exit_code: | |
| _PrintXcodeError('Remapping dSYMs process returned %i, please ' | |
| 'report a Tulsi bug and attach a full Xcode ' | |
| 'build log.' % exit_code) | |
| return exit_code | |
| else: | |
| timer = Timer('Patching DSYM source file paths', | |
| 'patching_dsym').Start() | |
| exit_code = self._PatchdSYMPaths(path) | |
| timer.End() | |
| if exit_code: | |
| return exit_code | |
| # Starting with Xcode 7.3, XCTests inject several supporting frameworks | |
| # into the test host that need to be signed with the same identity as | |
| # the host itself. | |
| if (self.is_test and self.xcode_version_minor >= 730 and | |
| not self.platform_name.startswith('macos') and | |
| self.codesigning_allowed): | |
| exit_code = self._ResignTestArtifacts() | |
| if exit_code: | |
| return exit_code | |
| # Starting with Xcode 8, .lldbinit files are honored during Xcode debugging | |
| # sessions. This allows use of the target.source-map field to remap the | |
| # debug symbol paths encoded in the binary to the paths expected by Xcode. | |
| # In cases where a dSYM bundle was produced, the post_processor will have | |
| # already corrected the paths and use of target.source-map is redundant (and | |
| # appears to trigger actual problems in Xcode 8.1 betas). The redundant path | |
| # correction applies to debug prefix maps as well. | |
| if self.xcode_version_major >= 800: | |
| timer = Timer('Updating .lldbinit', 'updating_lldbinit').Start() | |
| clear_source_map = self.generate_dsym or self.use_debug_prefix_map | |
| exit_code = self._UpdateLLDBInit(clear_source_map) | |
| timer.End() | |
| if exit_code: | |
| _PrintXcodeWarning('Updating .lldbinit action failed with code %d' % | |
| exit_code) | |
| if self.code_coverage_enabled: | |
| timer = Timer('Patching LLVM covmap', 'patching_llvm_covmap').Start() | |
| exit_code = self._PatchLLVMCovmapPaths() | |
| timer.End() | |
| if exit_code: | |
| _PrintXcodeWarning('Patch LLVM covmap action failed with code %d' % | |
| exit_code) | |
| return 0 | |
| def _BuildBazelCommand(self, options): | |
| """Builds up a commandline string suitable for running Bazel.""" | |
| bazel_command = [options.bazel_executable] | |
| configuration = os.environ['CONFIGURATION'] | |
| # Treat the special testrunner build config as a Debug compile. | |
| test_runner_config_prefix = '__TulsiTestRunner_' | |
| if configuration.startswith(test_runner_config_prefix): | |
| configuration = configuration[len(test_runner_config_prefix):] | |
| elif os.environ.get('TULSI_TEST_RUNNER_ONLY') == 'YES': | |
| _PrintXcodeError('Building test targets with configuration "%s" is not ' | |
| 'allowed. Please use the "Test" action or "Build for" > ' | |
| '"Testing" instead.' % configuration) | |
| return (None, 1) | |
| if configuration not in _OptionsParser.KNOWN_CONFIGS: | |
| _PrintXcodeError('Unknown build configuration "%s"' % configuration) | |
| return (None, 1) | |
| bazel_command.extend(options.GetStartupOptions(configuration)) | |
| bazel_command.append('build') | |
| bazel_command.extend(options.GetBuildOptions(configuration)) | |
| # Do not follow symlinks on __file__ in case this script is linked during | |
| # development. | |
| tulsi_package_dir = os.path.abspath( | |
| os.path.join(os.path.dirname(__file__), '..', 'Bazel')) | |
| bazel_command.extend([ | |
| '--build_event_json_file=%s' % self.build_events_file_path, | |
| # '--bes_outerr_buffer_size=0', # Waiting on a new external release. | |
| '--output_groups=tulsi-outputs,default', | |
| '--aspects', '@tulsi//tulsi:tulsi_aspects.bzl%tulsi_outputs_aspect', | |
| '--override_repository=tulsi=%s' % tulsi_package_dir, | |
| '--tool_tag=tulsi:bazel_build']) | |
| if self.code_coverage_enabled: | |
| self._PrintVerbose('Enabling code coverage information.') | |
| bazel_command.extend([ | |
| '--collect_code_coverage', | |
| '--experimental_use_llvm_covmap']) | |
| if self.generate_dsym: | |
| bazel_command.append('--apple_generate_dsym') | |
| bazel_command.append( | |
| '--define=bazel_rules_apple.propagate_embedded_extra_outputs=1') | |
| bazel_command.extend(options.targets) | |
| extra_options = bazel_options.BazelOptions(os.environ) | |
| bazel_command.extend(extra_options.bazel_feature_flags()) | |
| return (bazel_command, 0) | |
| def _RunBazelAndPatchOutput(self, command): | |
| """Runs subprocess command, patching output as it's received.""" | |
| self._PrintVerbose('Running "%s", patching output for main group path at ' | |
| '"%s" with project path at "%s".' % | |
| (' '.join(command), | |
| self.main_group_path, | |
| self.project_dir)) | |
| # Xcode translates anything that looks like ""<path>:<line>:" that is not | |
| # followed by the word "warning" into an error. Bazel warnings and debug | |
| # messages do not fit this scheme and must be patched here. | |
| bazel_warning_line_regex = re.compile( | |
| r'(?:DEBUG|WARNING): ([^:]+:\d+:(?:\d+:)?)\s+(.+)') | |
| def PatchBazelWarningStatements(output_line): | |
| match = bazel_warning_line_regex.match(output_line) | |
| if match: | |
| output_line = '%s warning: %s' % (match.group(1), match.group(2)) | |
| return output_line | |
| patch_xcode_parsable_line = PatchBazelWarningStatements | |
| if self.main_group_path != self.project_dir: | |
| # Match (likely) filename:line_number: lines. | |
| xcode_parsable_line_regex = re.compile(r'([^/][^:]+):\d+:') | |
| def PatchOutputLine(output_line): | |
| output_line = PatchBazelWarningStatements(output_line) | |
| if xcode_parsable_line_regex.match(output_line): | |
| output_line = '%s/%s' % (self.main_group_path, output_line) | |
| return output_line | |
| patch_xcode_parsable_line = PatchOutputLine | |
| def HandleOutput(output): | |
| for line in output.splitlines(): | |
| line = patch_xcode_parsable_line(line) + '\n' | |
| sys.stdout.write(line) | |
| sys.stdout.flush() | |
| def WatcherUpdate(watcher): | |
| """Processes any new events in the given watcher. | |
| Args: | |
| watcher: a BazelBuildEventsWatcher object. | |
| Returns: | |
| A list of new tulsiout file names seen. | |
| """ | |
| new_events = watcher.check_for_new_events() | |
| new_outputs = [] | |
| for build_event in new_events: | |
| if build_event.stderr: | |
| HandleOutput(build_event.stderr) | |
| if build_event.stdout: | |
| HandleOutput(build_event.stdout) | |
| if build_event.files: | |
| outputs = [x for x in build_event.files if x.endswith('.tulsiouts')] | |
| new_outputs.extend(outputs) | |
| return new_outputs | |
| # Make sure the BEP JSON file exists and is empty. We do this to prevent | |
| # any sort of race between the watcher, bazel, and the old file contents. | |
| open(self.build_events_file_path, 'w').close() | |
| # Start Bazel without any extra files open besides /dev/null, which is | |
| # used to ignore the output. | |
| with open(os.devnull, 'w') as devnull: | |
| process = subprocess.Popen(command, | |
| stdout=devnull, | |
| stderr=subprocess.STDOUT) | |
| # Register atexit function to clean up BEP file. | |
| atexit.register(_BEPFileExitCleanup, self.build_events_file_path) | |
| global CLEANUP_BEP_FILE_AT_EXIT | |
| CLEANUP_BEP_FILE_AT_EXIT = True | |
| with io.open(self.build_events_file_path, 'r', -1, 'utf-8', 'ignore' | |
| ) as bep_file: | |
| watcher = bazel_build_events.BazelBuildEventsWatcher(bep_file, | |
| _PrintXcodeWarning) | |
| output_locations = [] | |
| while process.returncode is None: | |
| output_locations.extend(WatcherUpdate(watcher)) | |
| time.sleep(0.1) | |
| process.poll() | |
| output_locations.extend(WatcherUpdate(watcher)) | |
| if process.returncode == 0 and not output_locations: | |
| CLEANUP_BEP_FILE_AT_EXIT = False | |
| _PrintXcodeError('Unable to find location of the .tulsiouts file.' | |
| 'Please report this as a Tulsi bug, including the' | |
| 'contents of %s.' % self.build_events_file_path) | |
| return 1, output_locations | |
| return process.returncode, output_locations | |
| def _InstallArtifact(self, outputs): | |
| """Installs Bazel-generated artifacts into the Xcode output directory.""" | |
| xcode_artifact_path = self.artifact_output_path | |
| if os.path.isdir(xcode_artifact_path): | |
| try: | |
| shutil.rmtree(xcode_artifact_path) | |
| except OSError as e: | |
| _PrintXcodeError('Failed to remove stale output directory ""%s". ' | |
| '%s' % (xcode_artifact_path, e)) | |
| return 600 | |
| elif os.path.isfile(xcode_artifact_path): | |
| try: | |
| os.remove(xcode_artifact_path) | |
| except OSError as e: | |
| _PrintXcodeError('Failed to remove stale output file ""%s". ' | |
| '%s' % (xcode_artifact_path, e)) | |
| return 600 | |
| try: | |
| output_data = json.load(open(outputs[0])) | |
| except (ValueError, IOError) as e: | |
| _PrintXcodeError('Failed to load output map ""%s". ' | |
| '%s' % (outputs[0], e)) | |
| return 600 | |
| if 'artifacts' not in output_data: | |
| _PrintXcodeError( | |
| 'Failed to find an output artifact for target %s in output map %r' % | |
| (xcode_artifact_path, output_data)) | |
| return 601 | |
| primary_artifact = output_data['artifacts'][0] | |
| # The PRODUCT_NAME used by the Xcode project is not trustable as it may be | |
| # modified by the user and, more importantly, may have been modified by | |
| # Tulsi to disambiguate multiple targets with the same name. | |
| # To work around this, the product name is determined by dropping any | |
| # extension from the primary artifact. | |
| # TODO(abaire): Consider passing this value to the script explicitly. | |
| self.bazel_product_name = os.path.splitext( | |
| os.path.basename(primary_artifact))[0] | |
| if primary_artifact.endswith('.ipa') or primary_artifact.endswith('.zip'): | |
| bundle_name = output_data.get('bundle_name') | |
| exit_code = self._UnpackTarget(primary_artifact, | |
| xcode_artifact_path, | |
| bundle_name) | |
| if exit_code: | |
| return exit_code | |
| elif os.path.isfile(primary_artifact): | |
| exit_code = self._CopyFile(os.path.basename(primary_artifact), | |
| primary_artifact, | |
| xcode_artifact_path) | |
| if exit_code: | |
| return exit_code | |
| else: | |
| self._CopyBundle(os.path.basename(primary_artifact), | |
| primary_artifact, | |
| xcode_artifact_path) | |
| # No return code check as this is not an essential operation. | |
| self._InstallEmbeddedBundlesIfNecessary(output_data) | |
| return 0 | |
| def _InstallEmbeddedBundlesIfNecessary(self, output_data): | |
| """Install embedded bundles next to the current target's output.""" | |
| # In order to find and load symbols for the binary installed on device, | |
| # Instruments needs to "see" it in Spotlight index somewhere on the local | |
| # filesystem. This is only needed for on-device instrumentation. | |
| # | |
| # Unfortunatelly, it does not seem to be possible to detect when a build is | |
| # being made for profiling, thus we can't exclude this step for on-device | |
| # non-profiling builds. | |
| if self.is_simulator or ('embedded_bundles' not in output_data): | |
| return | |
| timer = Timer('Installing embedded bundles', | |
| 'installing_embedded_bundles').Start() | |
| for bundle_info in output_data['embedded_bundles']: | |
| name = bundle_info['bundle_full_name'] | |
| # TODO(b/68936732): See if copying just the binary (not the whole bundle) | |
| # is enough to make Instruments work. | |
| source_path = os.path.join(bundle_info['archive_root'], name) | |
| output_path = os.path.join(self.built_products_dir, name) | |
| self._InstallBundle(source_path, output_path) | |
| timer.End() | |
| def _InstallGeneratedHeaders(self, output_files): | |
| """Installs Bazel-generated headers into _tulsi-includes directory.""" | |
| # The folder must begin with an underscore as otherwise Bazel will delete | |
| # it whenever it builds. See tulsi_aspects.bzl for futher explanation. | |
| tulsi_root = os.path.join(BAZEL_EXECUTION_ROOT, '_tulsi-includes') | |
| if os.path.exists(tulsi_root): | |
| shutil.rmtree(tulsi_root) | |
| else: | |
| os.mkdir(tulsi_root) | |
| for f in output_files: | |
| data = json.load(open(f)) | |
| if 'generated_sources' not in data: | |
| continue | |
| for gs in data['generated_sources']: | |
| real_path, link_path = gs | |
| src = os.path.join(BAZEL_EXECUTION_ROOT, real_path) | |
| # Bazel outputs are not guaranteed to be created if nothing references | |
| # them. This check skips the processing if an output was declared | |
| # but not created. | |
| if not os.path.exists(src): | |
| continue | |
| # The /x/x/ part is here to match the number of directory components | |
| # between tulsi root and bazel root. See tulsi_aspects.bzl for futher | |
| # explanation. | |
| dst = os.path.join(tulsi_root, 'x/x/', link_path) | |
| self._PrintVerbose('Symlinking %s to %s' % (src, dst), 2) | |
| dst_dir = os.path.split(dst)[0] | |
| if not os.path.exists(dst_dir): | |
| os.makedirs(dst_dir) | |
| # It's important to use lexists() here in case dst is a broken symlink | |
| # (in which case exists() would return False). For example, older | |
| # versions of this script did not check if src existed and could create | |
| # a symlink to an invalid path. | |
| if os.path.lexists(dst): | |
| os.unlink(dst) | |
| os.symlink(src, dst) | |
| def _InstallBundle(self, source_path, output_path): | |
| """Copies the bundle at source_path to output_path.""" | |
| if not os.path.isdir(source_path): | |
| return 0, None | |
| if os.path.isdir(output_path): | |
| try: | |
| shutil.rmtree(output_path) | |
| except OSError as e: | |
| _PrintXcodeError('Failed to remove stale bundle ""%s". ' | |
| '%s' % (output_path, e)) | |
| return 700, None | |
| exit_code = self._CopyBundle(os.path.basename(source_path), | |
| source_path, | |
| output_path) | |
| return exit_code, output_path | |
| def _CopyBundle(self, source_path, full_source_path, output_path): | |
| """Copies the given bundle to the given expected output path.""" | |
| self._PrintVerbose('Copying %s to %s' % (source_path, output_path)) | |
| try: | |
| shutil.copytree(full_source_path, output_path) | |
| except OSError as e: | |
| _PrintXcodeError('Copy failed. %s' % e) | |
| return 650 | |
| return 0 | |
| def _CopyFile(self, source_path, full_source_path, output_path): | |
| """Copies the given file to the given expected output path.""" | |
| self._PrintVerbose('Copying %s to %s' % (source_path, output_path)) | |
| output_path_dir = os.path.dirname(output_path) | |
| if not os.path.exists(output_path_dir): | |
| try: | |
| os.makedirs(output_path_dir) | |
| except OSError as e: | |
| _PrintXcodeError('Failed to create output directory ""%s". ' | |
| '%s' % (output_path_dir, e)) | |
| return 650 | |
| try: | |
| shutil.copy(full_source_path, output_path) | |
| except OSError as e: | |
| _PrintXcodeError('Copy failed. %s' % e) | |
| return 650 | |
| return 0 | |
| def _UnpackTarget(self, bundle_path, output_path, bundle_name): | |
| """Unpacks generated bundle into the given expected output path.""" | |
| self._PrintVerbose('Unpacking %s to %s' % (bundle_path, output_path)) | |
| if not os.path.isfile(bundle_path): | |
| _PrintXcodeError('Generated bundle not found at "%s"' % bundle_path) | |
| return 670 | |
| # We need to handle IPAs (from the native rules) differently from ZIPs | |
| # (from the Skylark rules) because they output slightly different directory | |
| # structures. | |
| is_ipa = bundle_path.endswith('.ipa') | |
| expected_bundle_name = bundle_name + self.wrapper_suffix | |
| # The directory structure within the IPA is then determined based on Bazel's | |
| # package and/or product type. | |
| if is_ipa: | |
| expected_bundle_subpath = os.path.join('Payload', expected_bundle_name) | |
| else: | |
| # If the artifact is a ZIP, assume that the bundle is the top-level | |
| # directory (this is the way in which Skylark rules package artifacts | |
| # that are not standalone IPAs). | |
| expected_bundle_subpath = expected_bundle_name | |
| with zipfile.ZipFile(bundle_path, 'r') as zf: | |
| for item in zf.infolist(): | |
| filename = item.filename | |
| # Support directories do not seem to be needed by the debugger and are | |
| # skipped. | |
| basedir = filename.split(os.sep)[0] | |
| if basedir.endswith('Support') or basedir.endswith('Support2'): | |
| continue | |
| if len(filename) < len(expected_bundle_subpath): | |
| continue | |
| attributes = (item.external_attr >> 16) & 0777 | |
| self._PrintVerbose('Extracting %s (%o)' % (filename, attributes), | |
| level=1) | |
| if not filename.startswith(expected_bundle_subpath): | |
| # TODO(abaire): Make an error if Bazel modifies this behavior. | |
| _PrintXcodeWarning('Mismatched extraction path. Bundle content ' | |
| 'at "%s" expected to have subpath of "%s"' % | |
| (filename, expected_bundle_subpath)) | |
| dir_components = self._SplitPathComponents(filename) | |
| # Get the file's path, ignoring the payload components if the archive | |
| # is an IPA. | |
| if is_ipa: | |
| subpath = os.path.join(*dir_components[2:]) | |
| else: | |
| subpath = os.path.join(*dir_components[1:]) | |
| target_path = os.path.join(output_path, subpath) | |
| # Ensure the target directory exists. | |
| try: | |
| target_dir = os.path.dirname(target_path) | |
| if not os.path.isdir(target_dir): | |
| os.makedirs(target_dir) | |
| except OSError as e: | |
| _PrintXcodeError( | |
| 'Failed to create target path "%s" during extraction. %s' % ( | |
| target_path, e)) | |
| return 671 | |
| # If the archive item looks like a file, extract it. | |
| if not filename.endswith(os.sep): | |
| with zf.open(item) as src, file(target_path, 'wb') as dst: | |
| shutil.copyfileobj(src, dst) | |
| # Patch up the extracted file's attributes to match the zip content. | |
| if attributes: | |
| os.chmod(target_path, attributes) | |
| return 0 | |
| def _InstallDSYMBundles(self, output_dir, aspect_outputs): | |
| """Copies any generated dSYM bundles to the given directory.""" | |
| target_dsym = os.environ.get('DWARF_DSYM_FILE_NAME') | |
| if not target_dsym: # If Xcode is not expecting a dSYM output, skip. | |
| return 0, None | |
| dsym_to_process = set([(self.build_path, target_dsym)]) | |
| # Collect additional dSYM bundles generated by the dependencies of this | |
| # build such as extensions or frameworks. | |
| child_dsyms = set() | |
| for path in aspect_outputs: | |
| try: | |
| data = json.load(open(path)) | |
| except (ValueError, IOError) as e: | |
| _PrintXcodeWarning('Failed to load output map ""%s".' | |
| '%s' % (path, e)) | |
| break | |
| for bundle_info in data.get('embedded_bundles', []): | |
| if not bundle_info['has_dsym']: | |
| continue | |
| # Uses the parent of archive_root to find dSYM bundles associated with | |
| # app/extension/df bundles. Currently hinges on implementation of the | |
| # build rules. | |
| dsym_path = os.path.dirname(bundle_info['archive_root']) | |
| dsym_filename = '%s.dSYM' % bundle_info['bundle_full_name'] | |
| child_dsyms.add((dsym_path, dsym_filename)) | |
| dsym_to_process.update(child_dsyms) | |
| dsyms_found = [] | |
| for dsym_path, dsym_filename in dsym_to_process: | |
| input_dsym_full_path = os.path.join(dsym_path, dsym_filename) | |
| output_full_path = os.path.join(output_dir, dsym_filename) | |
| exit_code, path = self._InstallBundle(input_dsym_full_path, | |
| output_full_path) | |
| if exit_code: | |
| _PrintXcodeWarning('Failed to install dSYM "%s" (%s)' | |
| % (target_dsym, exit_code)) | |
| elif path is None: | |
| _PrintXcodeWarning('Could not find a dSYM bundle named "%s"' | |
| % target_dsym) | |
| else: | |
| dsyms_found.append(path) | |
| return 0, dsyms_found | |
| def _ResignBundle(self, bundle_path, signing_identity, entitlements=None): | |
| """Re-signs the bundle with the given signing identity and entitlements.""" | |
| if not self.codesigning_allowed: | |
| return 0 | |
| timer = Timer('\tSigning ' + bundle_path, 'signing_bundle').Start() | |
| command = [ | |
| 'xcrun', | |
| 'codesign', | |
| '-f', | |
| '--timestamp=none', | |
| '-s', | |
| signing_identity, | |
| ] | |
| if entitlements: | |
| command.extend(['--entitlements', entitlements]) | |
| else: | |
| command.append('--preserve-metadata=entitlements') | |
| command.append(bundle_path) | |
| returncode, output = self._RunSubprocess(command) | |
| timer.End() | |
| if returncode: | |
| _PrintXcodeError('Re-sign command %r failed. %s' % (command, output)) | |
| return 800 + returncode | |
| return 0 | |
| def _ResignTestArtifacts(self): | |
| """Resign test related artifacts that Xcode injected into the outputs.""" | |
| if not self.is_test: | |
| return 0 | |
| # Extract the signing identity from the bundle at the expected output path | |
| # since that's where the signed bundle from bazel was placed. | |
| signing_identity = self._ExtractSigningIdentity(self.artifact_output_path) | |
| if not signing_identity: | |
| return 800 | |
| exit_code = 0 | |
| timer = Timer('Re-signing injected test host artifacts', | |
| 'resigning_test_host').Start() | |
| if self.test_host_binary: | |
| # For Unit tests, we need to resign the frameworks that Xcode injected | |
| # into the test host bundle. | |
| test_host_bundle = os.path.dirname(self.test_host_binary) | |
| exit_code = self._ResignXcodeTestFrameworks( | |
| test_host_bundle, signing_identity) | |
| else: | |
| # For UI tests, we need to resign the UI test runner app and the | |
| # frameworks that Xcode injected into the runner app. The UI Runner app | |
| # also needs to be signed with entitlements. | |
| exit_code = self._ResignXcodeTestFrameworks( | |
| self.codesigning_folder_path, signing_identity) | |
| if exit_code == 0: | |
| entitlements_path = self._InstantiateUIRunnerEntitlements() | |
| if entitlements_path: | |
| exit_code = self._ResignBundle( | |
| self.codesigning_folder_path, | |
| signing_identity, | |
| entitlements_path) | |
| else: | |
| _PrintXcodeError('Could not instantiate UI runner entitlements.') | |
| exit_code = 800 | |
| timer.End() | |
| return exit_code | |
| def _ResignXcodeTestFrameworks(self, bundle, signing_identity): | |
| """Re-signs the support frameworks injected by Xcode in the given bundle.""" | |
| if not self.codesigning_allowed: | |
| return 0 | |
| for framework in XCODE_INJECTED_FRAMEWORKS: | |
| framework_path = os.path.join( | |
| bundle, 'Frameworks', '%s.framework' % framework) | |
| if os.path.isdir(framework_path): | |
| exit_code = self._ResignBundle(framework_path, signing_identity) | |
| if exit_code != 0: | |
| return exit_code | |
| return 0 | |
| def _InstantiateUIRunnerEntitlements(self): | |
| """Substitute team and bundle identifiers into UI runner entitlements. | |
| This method throws an IOError exception if the template wasn't found in | |
| its expected location, or an OSError if the expected output folder could | |
| not be created. | |
| Returns: | |
| The path to where the entitlements file was generated. | |
| """ | |
| if not self.codesigning_allowed: | |
| return None | |
| if not os.path.exists(self.derived_sources_folder_path): | |
| os.makedirs(self.derived_sources_folder_path) | |
| output_file = os.path.join( | |
| self.derived_sources_folder_path, | |
| self.bazel_product_name + '_UIRunner.entitlements') | |
| if os.path.exists(output_file): | |
| os.remove(output_file) | |
| with open(self.runner_entitlements_template, 'r') as template: | |
| contents = template.read() | |
| contents = contents.replace( | |
| '$(TeamIdentifier)', | |
| self._ExtractSigningTeamIdentifier(self.artifact_output_path)) | |
| contents = contents.replace( | |
| '$(BundleIdentifier)', | |
| self._ExtractSigningBundleIdentifier(self.artifact_output_path)) | |
| with open(output_file, 'w') as output: | |
| output.write(contents) | |
| return output_file | |
| def _ExtractSigningIdentity(self, signed_bundle): | |
| """Returns the identity used to sign the given bundle path.""" | |
| return self._ExtractSigningAttribute(signed_bundle, 'Authority') | |
| def _ExtractSigningTeamIdentifier(self, signed_bundle): | |
| """Returns the team identifier used to sign the given bundle path.""" | |
| return self._ExtractSigningAttribute(signed_bundle, 'TeamIdentifier') | |
| def _ExtractSigningBundleIdentifier(self, signed_bundle): | |
| """Returns the bundle identifier used to sign the given bundle path.""" | |
| return self._ExtractSigningAttribute(signed_bundle, 'Identifier') | |
| def _ExtractSigningAttribute(self, signed_bundle, attribute): | |
| """Returns the attribute used to sign the given bundle path.""" | |
| if not self.codesigning_allowed: | |
| return '<CODE_SIGNING_ALLOWED=NO>' | |
| cached = self.codesign_attributes.get(signed_bundle) | |
| if cached: | |
| return cached.Get(attribute) | |
| timer = Timer('\tExtracting signature for ' + signed_bundle, | |
| 'extracting_signature').Start() | |
| output = subprocess.check_output(['xcrun', | |
| 'codesign', | |
| '-dvv', | |
| signed_bundle], | |
| stderr=subprocess.STDOUT) | |
| timer.End() | |
| bundle_attributes = CodesignBundleAttributes(output) | |
| self.codesign_attributes[signed_bundle] = bundle_attributes | |
| return bundle_attributes.Get(attribute) | |
| _TULSI_LLDBINIT_BLOCK_START = '# <TULSI> LLDB bridge [:\n' | |
| _TULSI_LLDBINIT_BLOCK_END = '# ]: <TULSI> LLDB bridge\n' | |
| _TULSI_LLDBINIT_FILE = os.path.expanduser('~/.lldbinit-tulsiproj') | |
| _TULSI_LLDBINIT_EPILOGUE_FILE = ( | |
| os.path.expanduser('~/.lldbinit-tulsiproj-epilogue')) | |
| def _ExtractLLDBInitContent(self, lldbinit_path): | |
| """Extracts the non-Tulsi content of the given lldbinit file.""" | |
| if not os.path.isfile(lldbinit_path): | |
| return [] | |
| content = [] | |
| with open(lldbinit_path) as f: | |
| ignoring = False | |
| for line in f: | |
| if ignoring: | |
| if line == self._TULSI_LLDBINIT_BLOCK_END: | |
| ignoring = False | |
| continue | |
| if line == self._TULSI_LLDBINIT_BLOCK_START: | |
| ignoring = True | |
| continue | |
| content.append(line) | |
| return content | |
| def _LinkTulsiLLDBInit(self): | |
| """Adds a reference to ~/.lldbinit-tulsi to the primary lldbinit file. | |
| Xcode 8+ caches the contents of ~/.lldbinit-Xcode on startup. To get around | |
| this, an external reference to ~/.lldbinit-tulsi is added, causing LLDB | |
| itself to load the possibly modified contents on each session. | |
| """ | |
| lldbinit_path = os.path.expanduser('~/.lldbinit-Xcode') | |
| if not os.path.isfile(lldbinit_path): | |
| lldbinit_path = os.path.expanduser('~/.lldbinit') | |
| content = self._ExtractLLDBInitContent(lldbinit_path) | |
| out = StringIO.StringIO() | |
| for line in content: | |
| out.write(line) | |
| out.write(self._TULSI_LLDBINIT_BLOCK_START) | |
| out.write('# This was autogenerated by Tulsi in order to influence LLDB ' | |
| 'source-maps at build time.\n') | |
| out.write('command source %s\n' % self._TULSI_LLDBINIT_FILE) | |
| out.write(self._TULSI_LLDBINIT_BLOCK_END) | |
| with open(lldbinit_path, 'w') as outfile: | |
| out.seek(0) | |
| # Negative length to make copyfileobj write the whole file at once. | |
| shutil.copyfileobj(out, outfile, -1) | |
| def _LinkTulsiLLDBInitEpilogue(self, outfile): | |
| """Adds a reference to ~/.lldbinit-tulsi-epilogue if it exists. | |
| This file can be used to append more LLDB commands right after | |
| .lldbinit-tulsi is sourced. | |
| Useful for extending or resetting LLDB settings that Tulsi may have set | |
| automatically | |
| Args: | |
| outfile: a file-type object. | |
| Returns: | |
| None | |
| """ | |
| if os.path.isfile(self._TULSI_LLDBINIT_EPILOGUE_FILE): | |
| outfile.write('command source %s\n' % self._TULSI_LLDBINIT_EPILOGUE_FILE) | |
| def _UpdateLLDBInit(self, clear_source_map=False): | |
| """Updates ~/.lldbinit-tulsi to enable debugging of Bazel binaries.""" | |
| # Apple Watch app binaries do not contain any sources. | |
| if self.product_type == 'com.apple.product-type.application.watchapp2': | |
| return 0 | |
| self._LinkTulsiLLDBInit() | |
| with open(self._TULSI_LLDBINIT_FILE, 'w') as out: | |
| out.write('# This file is autogenerated by Tulsi and should not be ' | |
| 'edited.\n') | |
| if clear_source_map: | |
| out.write('settings clear target.source-map\n') | |
| self._LinkTulsiLLDBInitEpilogue(out) | |
| return 0 | |
| timer = Timer( | |
| '\tExtracting source paths for ' + self.full_product_name, | |
| 'extracting_source_paths').Start() | |
| execroot = self._ExtractExecroot() | |
| timer.End() | |
| if not execroot: | |
| _PrintXcodeWarning('Could not find the execroot from %r. File-based ' | |
| 'breakpoints may not work. Please report as a bug.' % | |
| self.full_product_name) | |
| return 0 | |
| source_map = (self._NormalizePath(execroot), | |
| self._NormalizePath(self.workspace_root)) | |
| out.write('# This maps Bazel\'s execution root to that used by %r.\n' % | |
| os.path.basename(self.project_file_path)) | |
| out.write('settings set target.source-map "%s" "%s"\n' % source_map) | |
| self._LinkTulsiLLDBInitEpilogue(out) | |
| return 0 | |
| def _PatchLLVMCovmapPaths(self): | |
| """Invokes post_processor to fix source paths in LLVM coverage maps.""" | |
| if not os.path.isfile(self.binary_path): | |
| return 0 | |
| self._PrintVerbose('Patching %r -> %r' % (BAZEL_EXECUTION_ROOT, | |
| self.workspace_root), 1) | |
| args = [ | |
| self.post_processor_binary, | |
| '-c', | |
| ] | |
| if self.verbose > 1: | |
| args.append('-v') | |
| args.extend([ | |
| self.binary_path, | |
| BAZEL_EXECUTION_ROOT, | |
| self.workspace_root | |
| ]) | |
| returncode, output = self._RunSubprocess(args) | |
| if returncode: | |
| _PrintXcodeWarning('Coverage map patching failed on binary %r (%d). Code ' | |
| 'coverage will probably fail.' % | |
| (self.binary_path, returncode)) | |
| _PrintXcodeWarning('Output: %s' % output or '<no output>') | |
| return 0 | |
| return 0 | |
| def _DWARFdSYMBinaries(self, dsym_bundle_path): | |
| """Returns an array of abs paths to DWARF binaries in the dSYM bundle. | |
| Args: | |
| dsym_bundle_path: absolute path to the dSYM bundle. | |
| Returns: | |
| str[]: a list of strings representing the absolute paths to each binary | |
| found within the dSYM bundle. | |
| """ | |
| dwarf_dir = os.path.join(dsym_bundle_path, | |
| 'Contents', | |
| 'Resources', | |
| 'DWARF') | |
| dsym_binaries = [] | |
| for f in os.listdir(dwarf_dir): | |
| # Ignore hidden files, such as .DS_Store files. | |
| if not f.startswith('.'): | |
| # Append full path info. | |
| dsym_binary = os.path.join(dwarf_dir, f) | |
| dsym_binaries.append(dsym_binary) | |
| return dsym_binaries | |
| def _UUIDsForBinaryAtPath(self, source_binary_path): | |
| """Returns exit code of dwarfdump along with every UUID found for a binary. | |
| Args: | |
| source_binary_path: absolute path to the binary file. | |
| Returns: | |
| (Int, str[]): a tuple containing the return code of dwarfdump as its | |
| first element, and a list of strings representing each UUID | |
| found for each given binary slice found within the binary, | |
| if no error has occcured. | |
| """ | |
| returncode, output = self._RunSubprocess([ | |
| 'xcrun', | |
| 'dwarfdump', | |
| '--uuid', | |
| source_binary_path | |
| ]) | |
| if returncode: | |
| _PrintXcodeWarning('dwarfdump returned %d while finding the UUID for %s' | |
| % (returncode, source_binary_path)) | |
| return (returncode, []) | |
| # All UUIDs for binary slices will be returned as the second from left, | |
| # from output; "UUID: D4DE5AA2-79EE-36FE-980C-755AED318308 (x86_64) | |
| # /Applications/Calendar.app/Contents/MacOS/Calendar" | |
| uuids_found = [] | |
| for dwarfdump_output in output.split('\n'): | |
| if not dwarfdump_output: | |
| continue | |
| found_output = re.match(r'^(?:UUID: )([^ ]+)', dwarfdump_output) | |
| if not found_output: | |
| continue | |
| found_uuid = found_output.group(1) | |
| if found_uuid: | |
| uuids_found.append(found_uuid) | |
| return (0, uuids_found) | |
| def _CreateUUIDPlist(self, dsym_bundle_path, uuid, source_maps): | |
| """Creates a UUID.plist in a dSYM bundle to redirect sources. | |
| Args: | |
| dsym_bundle_path: absolute path to the dSYM bundle. | |
| uuid: string representing the UUID of the binary slice with paths to | |
| remap in the dSYM bundle. | |
| source_maps: a set of tuples representing all absolute paths to source | |
| files compiled by Bazel as strings ($0) associated with the | |
| paths to Xcode-visible sources used for the purposes of | |
| Tulsi debugging as strings ($1). | |
| Returns: | |
| Int: the return code of plutil if a non-zero return code was found, or | |
| "405", representing a failed copy action when creating the plist. | |
| """ | |
| # Create a UUID plist at (dsym_bundle_path)/Contents/Resources/ from | |
| # the plist that was already generated within the dSYM bundle. | |
| remap_plist = os.path.join(dsym_bundle_path, | |
| 'Contents', | |
| 'Resources', | |
| '%s.plist' % uuid) | |
| main_plist = os.path.join(dsym_bundle_path, | |
| 'Contents', | |
| 'Info.plist') | |
| try: | |
| shutil.copyfile(main_plist, remap_plist) | |
| except IOError as e: | |
| _PrintXcodeError('Failed to copy %s to %s, received error %s' % | |
| (main_plist, remap_plist, e)) | |
| return 405 | |
| # Via plutil, add the mappings from _ExtractTargetSourceMaps(). Make | |
| # sure that we also set DBGVersion to 2 via plutil. | |
| returncode, output = self._RunSubprocess([ | |
| 'xcrun', | |
| 'plutil', | |
| '-replace', | |
| 'DBGVersion', | |
| '-string', | |
| '"2"', | |
| remap_plist | |
| ]) | |
| if returncode: | |
| _PrintXcodeWarning('plutil returned %d while adding DBGVersion to %s: %s' | |
| % (returncode, remap_plist, output)) | |
| return returncode | |
| json_path_remappings = '' | |
| for source_map in source_maps: | |
| json_path_remappings += '"%s" : "%s", ' % source_map | |
| # Add each mapping as a DBGSourcePathRemapping to the UUID plist here. | |
| returncode, output = self._RunSubprocess([ | |
| 'xcrun', | |
| 'plutil', | |
| '-replace', | |
| 'DBGSourcePathRemapping', | |
| '-json', | |
| '{ ' + json_path_remappings + ' }', | |
| remap_plist | |
| ]) | |
| if returncode: | |
| _PrintXcodeWarning('plutil returned %d while adding ' | |
| 'DBGSourcePathRemapping to %s: %s' | |
| % (returncode, remap_plist, output)) | |
| return returncode | |
| return 0 | |
| def _PlistdSYMPaths(self, dsym_bundle_path): | |
| """Adds Plists to a given dSYM bundle to redirect DWARF data.""" | |
| # Retrieve all paths that we are expected to remap. | |
| source_maps = self._ExtractTargetSourceMaps() | |
| if not source_maps: | |
| _PrintXcodeWarning('Extracted 0 source paths. File-based breakpoints ' | |
| 'may not work. Please report as a bug.') | |
| return 410 | |
| # Find the binaries within the dSYM bundle. UUIDs will match that of the | |
| # binary it was based on. | |
| dsym_binaries = self._DWARFdSYMBinaries(dsym_bundle_path) | |
| if not dsym_binaries: | |
| _PrintXcodeWarning('Could not find the binaries that the dSYM %s was ' | |
| 'based on to determine DWARF binary slices to patch. ' | |
| 'Debugging will probably fail.' % (dsym_bundle_path)) | |
| return 404 | |
| # Find the binary slice UUIDs with dwarfdump from each binary. | |
| for source_binary_path in dsym_binaries: | |
| returncode, uuids_found = self._UUIDsForBinaryAtPath(source_binary_path) | |
| if returncode: | |
| return returncode | |
| # Create a plist per UUID, each indicating a binary slice to remap paths. | |
| for uuid in uuids_found: | |
| returncode = self._CreateUUIDPlist(dsym_bundle_path, uuid, source_maps) | |
| if returncode: | |
| return returncode | |
| # Update spotlight index with this updated dSYM bundle in case the binary's | |
| # UUID changed. | |
| self._RunSubprocess(['mdimport', dsym_bundle_path]) | |
| return 0 | |
| def _PatchdSYMPaths(self, dsym_bundle_path): | |
| """Invokes post_processor to fix source paths in dSYM DWARF data.""" | |
| dwarf_subpath = os.path.join(dsym_bundle_path, | |
| 'Contents', | |
| 'Resources', | |
| 'DWARF') | |
| binaries = [os.path.join(dwarf_subpath, b) | |
| for b in os.listdir(dwarf_subpath)] | |
| for binary_path in binaries: | |
| os.chmod(binary_path, 0755) | |
| args = [self.post_processor_binary, '-d'] | |
| if self.verbose > 1: | |
| args.append('-v') | |
| args.extend(binaries) | |
| args.extend([BAZEL_EXECUTION_ROOT, self.workspace_root]) | |
| self._PrintVerbose('Patching %r -> %r' % (BAZEL_EXECUTION_ROOT, | |
| self.workspace_root), 1) | |
| returncode, output = self._RunSubprocess(args) | |
| if returncode: | |
| _PrintXcodeWarning('DWARF path patching failed on dSYM %r (%d). ' | |
| 'Breakpoints and other debugging actions will ' | |
| 'probably fail.' % (dsym_bundle_path, returncode)) | |
| _PrintXcodeWarning('Output: %s' % output or '<no output>') | |
| return 0 | |
| return 0 | |
| def _ExtractBazelInfoExecrootPaths(self): | |
| """Extracts the path to the execution root found in this WORKSPACE. | |
| Returns: | |
| None: if an error occurred. | |
| str: a string representing the absolute path to the execution root found | |
| for the current Bazel WORKSPACE. | |
| """ | |
| if not self.bazel_executable: | |
| _PrintXcodeWarning('Attempted to find the execution root, but the ' | |
| 'path to the Bazel executable was not provided.') | |
| return None | |
| timer = Timer('Finding Bazel execution root', 'bazel_execroot').Start() | |
| returncode, output = self._RunSubprocess([ | |
| self.bazel_executable, | |
| 'info', | |
| 'execution_root', | |
| '--noshow_loading_progress', | |
| '--noshow_progress', | |
| ]) | |
| timer.End() | |
| if returncode: | |
| _PrintXcodeWarning('%s returned %d while finding the execution root' | |
| % (self.bazel_executable, returncode)) | |
| return None | |
| for line in output.splitlines(): | |
| # Filter out output that does not contain the /execroot path. | |
| if '/execroot' not in line: | |
| continue | |
| # Return the path from the first /execroot found. | |
| return line | |
| _PrintXcodeWarning('%s did not return a recognized /execroot path.' | |
| % self.bazel_executable) | |
| return None | |
| def _NormalizePath(self, path): | |
| """Returns paths with a common form, normalized with a trailing slash. | |
| Args: | |
| path: a file system path given in the form of a string. | |
| Returns: | |
| str: a normalized string with a trailing slash, based on |path|. | |
| """ | |
| return os.path.normpath(path) + os.sep | |
| def _ExtractTargetSourceMaps(self): | |
| """Extracts all source paths as tuples associated with the WORKSPACE path. | |
| Returns: | |
| set(): if an error occurred. | |
| set(str, str): a set of tuples representing all absolute paths to source | |
| files compiled by Bazel as strings ($0) associated with | |
| the paths to Xcode-visible sources used for the purposes | |
| of Tulsi debugging as strings ($1). | |
| """ | |
| source_maps = set() | |
| # All paths route to the "workspace root" for sources visible from Xcode. | |
| sm_wsroot = self._NormalizePath(self.workspace_root) | |
| # If the user has specified any additional mappings, add them first. | |
| if self.extra_remap_path: | |
| source_maps.add((self._NormalizePath(self.extra_remap_path), sm_wsroot)) | |
| # Add a redirection for the Bazel execution root, the path where sources | |
| # are referenced by Bazel. | |
| execroot = self._ExtractExecroot() | |
| if execroot: | |
| source_maps.add((self._NormalizePath(execroot), sm_wsroot)) | |
| return source_maps | |
| def _ExtractExecroot(self): | |
| """Finds the execution root from BAZEL_EXECUTION_ROOT or bazel info. | |
| Returns: | |
| None: if an error occurred. | |
| str: the "execution root", the path to the "root" of all source files | |
| compiled by Bazel as a string. | |
| """ | |
| # If we have a cached execution root, check that it exists. | |
| if os.path.exists(BAZEL_EXECUTION_ROOT): | |
| # If so, use it. | |
| execroot = BAZEL_EXECUTION_ROOT | |
| else: | |
| # Query Bazel directly for the execution root. | |
| execroot = self._ExtractBazelInfoExecrootPaths() | |
| return execroot | |
| def _LinkTulsiWorkspace(self): | |
| """Links the Bazel Workspace to the Tulsi Workspace (`tulsi-workspace`).""" | |
| tulsi_workspace = self.workspace_root + '/tulsi-workspace' | |
| if os.path.islink(tulsi_workspace): | |
| os.unlink(tulsi_workspace) | |
| os.symlink(BAZEL_EXECUTION_ROOT, tulsi_workspace) | |
| if not os.path.exists(tulsi_workspace): | |
| _PrintXcodeError( | |
| 'Linking Tulsi Workspace to %s failed.' % tulsi_workspace) | |
| return -1 | |
| def _PrintSpotlightDisabledMessaging(self): | |
| """Prints errors to the console indicating that Spotlight is required.""" | |
| spotlight_required_msg = ('Spotlight is needed to find debugging info ' | |
| 'for Bazel-built sources.') | |
| spotlight_enable_msg = ('Please enable Spotlight with `sudo mdutil -i on /`' | |
| ' in the Terminal.') | |
| spotlight_check_disable_msg = ('If you need to disable this check and ' | |
| 'proceed with a compromised debugging ' | |
| 'experience set %s to YES.' % | |
| BazelBuildBridge.SPOTLIGHT_CHECK_ENVVAR) | |
| _PrintXcodeError(spotlight_required_msg) | |
| _PrintXcodeError(spotlight_enable_msg) | |
| _PrintXcodeWarning(spotlight_check_disable_msg) | |
| def _CheckSpotlightStatus(self): | |
| """Check if Spotlight has been enabled on root, error if it hasn't been. | |
| Returns: | |
| Int: 0 if Spotlight reports that indexing is enabled on the root dir. | |
| -1 if Spotlight indexing was not found to be enabled on the root | |
| dir. The return code if the mdutil query on the root dir failed | |
| to execute properly. | |
| """ | |
| sys.stdout.write('Checking Spotlight status on the startup disk.\n') | |
| sys.stdout.flush() | |
| returncode, output = self._RunSubprocess([ | |
| 'mdutil', | |
| '-s', | |
| '/' | |
| ]) | |
| output_single_line = output.replace('\n', '').replace('\t', ' ') | |
| if returncode != 0: | |
| _PrintXcodeError('Could not verify status of Spotlight on the startup ' | |
| 'disk.') | |
| _PrintXcodeError('mdutil exited with %s: "%s".' % (returncode, | |
| output_single_line)) | |
| self._PrintSpotlightDisabledMessaging() | |
| return returncode | |
| # Attempt to match on "Indexing enabled." and any possible variants. | |
| if 'enabled' not in output: | |
| _PrintXcodeError('Spotlight has been turned off on the startup disk.') | |
| _PrintXcodeError('Status returned from mdutil was "%s".' % | |
| output_single_line) | |
| self._PrintSpotlightDisabledMessaging() | |
| return -1 | |
| return 0 | |
| @staticmethod | |
| def _SplitPathComponents(path): | |
| """Splits the given path into an array of all of its components.""" | |
| components = path.split(os.sep) | |
| # Patch up the first component if path started with an os.sep | |
| if not components[0]: | |
| components[0] = os.sep | |
| return components | |
| def _RunSubprocess(self, cmd): | |
| """Runs the given command as a subprocess, returning (exit_code, output).""" | |
| self._PrintVerbose('%r' % cmd, 1) | |
| process = subprocess.Popen(cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT) | |
| output, _ = process.communicate() | |
| return (process.returncode, output) | |
| def _PrintVerbose(self, msg, level=0): | |
| if self.verbose > level: | |
| sys.stdout.write(msg + '\n') | |
| sys.stdout.flush() | |
| if __name__ == '__main__': | |
| _LockFileAcquire('/tmp/tulsi_bazel_build.lock') | |
| _timer = Timer('Everything', 'complete_build').Start() | |
| signal.signal(signal.SIGINT, _InterruptHandler) | |
| _exit_code = BazelBuildBridge().Run(sys.argv) | |
| _timer.End() | |
| sys.exit(_exit_code) |