# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to Xcode on Apple platforms."""
# pylint: disable=too-many-lines
from __future__ import annotations
import json
import os
import sys
import time
import shlex
import logging
import tempfile
import subprocess
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, assert_never
from dataclasses import dataclass
from filelock import FileLock
from efro.terminal import Clr
from efro.error import CleanError
from efro.dataclassio import ioprepped, dataclass_from_dict
from efrotools.project import getlocalconfig # pylint: disable=C0411
if TYPE_CHECKING:
from typing import Any
[docs]
@ioprepped
@dataclass
class SigningConfig:
"""Info about signing."""
certfile: str
certpass: str
# HOW THIS WORKS:
# Basically we scan line beginnings for the following words followed by
# spaces. When we find one, we switch our section to that until a different
# section header is found. We then call parse/print functions with new lines
# depending on the current section.
# Section line parsing/printing is generally blacklist based; if we recognize
# a line is not needed we can ignore it. We generally look for specific
# strings or string-endings in a line to do this. We want to be sure to print
# anything we don't recognize to avoid hiding important output.
# I'm sure this system isn't 100% accurate with threads spitting out
# overlapping output and whatnot, but it hopefully works 'well enough'.
class _Section(Enum):
COMPILEC = 'CompileC'
COMPILEXCSTRINGS = 'CompileXCStrings'
SWIFTCOMPILE = 'SwiftCompile'
SWIFTGENERATEPCH = 'SwiftGeneratePch'
SWIFTDRIVER = 'SwiftDriver'
SWIFTDRIVERJOBDISCOVERY = 'SwiftDriverJobDiscovery'
CONSTRUCTSTUBEXECUTORLINKFILELIST = 'ConstructStubExecutorLinkFileList'
SWIFTEMITMODULE = 'SwiftEmitModule'
COMPILESWIFT = 'CompileSwift'
MKDIR = 'MkDir'
LD = 'Ld'
CPRESOURCE = 'CpResource'
COMPILEASSETCATALOG = 'CompileAssetCatalog'
CODESIGN = 'CodeSign'
COMPILESTORYBOARD = 'CompileStoryboard'
CONVERTICONSETFILE = 'ConvertIconsetFile'
LINKSTORYBOARDS = 'LinkStoryboards'
PRECOMPILESWIFTBRIDGINGHEADER = 'PrecompileSwiftBridgingHeader'
PROCESSINFOPLISTFILE = 'ProcessInfoPlistFile'
COPYSWIFTLIBS = 'CopySwiftLibs'
REGISTEREXECUTIONPOLICYEXCEPTION = 'RegisterExecutionPolicyException'
VALIDATE = 'Validate'
TOUCH = 'Touch'
REGISTERWITHLAUNCHSERVICES = 'RegisterWithLaunchServices'
METALLINK = 'MetalLink'
CREATEBUILDDIRECTORY = 'CreateBuildDirectory'
COMPILEMETALFILE = 'CompileMetalFile'
COPY = 'Copy'
COPYSTRINGSFILE = 'CopyStringsFile'
WRITEAUXILIARYFILE = 'WriteAuxiliaryFile'
COMPILESWIFTSOURCES = 'CompileSwiftSources'
PROCESSPCH = 'ProcessPCH'
PROCESSPCHPLUSPLUS = 'ProcessPCH++'
PHASESCRIPTEXECUTION = 'PhaseScriptExecution'
PROCESSPRODUCTPACKAGING = 'ProcessProductPackaging'
PROCESSPRODUCTPACKAGINGDER = 'ProcessProductPackagingDER'
CLANGSTATCACHE = 'ClangStatCache'
EXTRACTAPPINTENTSMETADATA = 'ExtractAppIntentsMetadata'
SWIFTMERGEGENERATEDHEADERS = 'SwiftMergeGeneratedHeaders'
GENERATEDSYMFILE = 'GenerateDSYMFile'
GENERATEASSETSYMBOLS = 'GenerateAssetSymbols'
[docs]
class XCodeBuild:
"""xcodebuild wrapper with extra bells and whistles."""
def __init__(self, projroot: str, args: list[str]):
self._projroot = projroot
self._args = args
self._output: list[str] = []
self._verbose = os.environ.get('XCODEBUILDVERBOSE', '0') == '1'
self._section: _Section | None = None
self._section_line_count = 0
self._returncode: int | None = None
self._project: str | None = (
self._argstr(args, '-project') if '-project' in args else None
)
self._scheme: str | None = (
self._argstr(args, '-scheme') if '-scheme' in args else None
)
self._configuration: str | None = (
self._argstr(args, '-configuration')
if '-configuration' in args
else None
)
# Use random name for temp keychains to hopefully avoid collisions
# and make snooping harder.
self._keychain_name = f'build{os.urandom(8).hex()}.keychain'
self._keychain_pass = os.urandom(16).hex()
self._signingconfigname: str | None = None
self._signingconfig: SigningConfig | None = None
if '-baSigningConfig' in args:
self._signingconfigname = self._argstr(
args, '-baSigningConfig', remove=True
)
lconfig = getlocalconfig(projroot=Path(projroot))
if self._signingconfigname not in lconfig.get(
'apple_signing_configs', {}
):
raise CleanError(
f"Error: Signing-config '{self._signingconfigname}'"
' is not present in localconfig.'
)
try:
self._signingconfig = dataclass_from_dict(
SigningConfig,
lconfig['apple_signing_configs'][self._signingconfigname],
)
if not os.path.exists(self._signingconfig.certfile):
raise RuntimeError(
f'Certfile not found at'
f" '{self._signingconfig.certfile}'."
)
except Exception:
logging.exception(
"Error loading signing-config '%s'.",
self._signingconfigname,
)
[docs]
def run(self) -> None:
"""Do the thing."""
# Ok here's the deal:
# First I tried creating file-locks only while creating or destroying
# temp keychains that we use for builds, but I'm still seeing random
# code signing failures when 2 builds overlap.
# So now I'm going with a single lock that is held throughout the
# entire build when code signing is involved. We'll see if that works.
# If that seems to slow things down too much we can try something in
# the middle such as enforcing a cooldown period after making keychain
# changes or something like that.
# Go with a lock for the full build duration only if there's signing
# involved.
if self._signingconfig is not None:
wait_start_time = time.monotonic()
print('Waiting for xcode build lock...', flush=True)
with self._get_build_file_lock():
wait_time = time.monotonic() - wait_start_time
print(
f'Xcode build lock acquired in {wait_time:.2f} seconds.',
flush=True,
)
self._run()
else:
self._run()
def _run(self) -> None:
self._set_up_keychain()
try:
self._run_cmd(self._build_cmd_args())
assert self._returncode is not None
# In some failure cases we may want to run a clean and try again.
if self._returncode != 0:
if self._returncode == 65:
# Signing error.
raise CleanError(
'Build failed with code 65 (signing error).\n'
'Make sure the new device is registered in your'
' provisioning profile - just build AND run something'
' manually in xcode to do so\n.'
# 'To upgrade/fix signing config for a new device:\n'
# ' 1: Manually open xcode project on new device.\n'
# ' 2: Run builds for all platforms to update'
# ' auto-signing.\n'
# ' 3: Export that signing config to expected location'
# ' with expected password (see localconfig).'
)
# Getting this error sometimes after xcode updates.
if (
'error: PCH file built from a different branch'
in '\n'.join(self._output)
):
# Assume these were all passed for the build that just
# failed.
assert self._project is not None
assert self._scheme is not None
assert self._configuration is not None
print(
f'{Clr.MAG}WILL CLEAN AND'
f' RE-ATTEMPT XCODE BUILD{Clr.RST}'
)
self._run_cmd(
[
'xcodebuild',
'-project',
self._project,
'-scheme',
self._scheme,
'-configuration',
self._configuration,
'clean',
]
)
# Now re-run the original build.
print(
f'{Clr.MAG}RE-ATTEMPTING XCODE BUILD'
f' AFTER CLEAN{Clr.RST}'
)
self._run_cmd(self._build_cmd_args())
if self._returncode != 0:
raise CleanError(
f'Command failed with code {self._returncode}.'
)
finally:
self._tear_down_keychain()
def _get_keychain_file_lock(self) -> FileLock:
"""Return a lock that we hold while mucking with keychain stuff."""
path = os.path.join(tempfile.gettempdir(), 'ba_xc_keychain_lock')
return FileLock(path)
def _get_build_file_lock(self) -> FileLock:
"""Return a lock that we hold for an entire build."""
path = os.path.join(tempfile.gettempdir(), 'ba_xc_build_lock')
return FileLock(path)
def _set_up_keychain(self) -> None:
# If we're specifying a signing configuration, this sets it up
# via a temporary keychain.
# As seen in https://github.com/Apple-Actions/import-codesign-certs
# And similarly https://xcodebuild.tips/pages/certificates-and-keys/
if self._signingconfig is None:
return
# We're mucking with keychain settings here which is a global thing.
# Let's try to at least avoid two of us mucking with it at once.
assert self._signingconfigname is not None
with self._get_keychain_file_lock():
print(f"Setting up signing-config '{self._signingconfigname}'...")
# Create a new temp keychain.
subprocess.run(
[
'security',
'create-keychain',
'-p',
self._keychain_pass,
self._keychain_name,
],
check=True,
capture_output=True,
)
# Grab list of current keychains.
keychains = [
line.strip().replace('"', '')
for line in subprocess.run(
['security', 'list-keychains', '-d', 'user'],
check=True,
capture_output=True,
)
.stdout.decode()
.splitlines()
]
# Warn if we're seeing keychain leaks/etc.
if len(keychains) != 1:
print(
f'{Clr.RED}Expected to initially find 1 keychain;'
f' got {keychains}{Clr.RST}'
)
assert all(os.path.exists(p) for p in keychains)
keychains.insert(0, self._keychain_name)
subprocess.run(
['security', 'list-keychains', '-d', 'user', '-s'] + keychains,
check=True,
)
subprocess.run(
[
'security',
'unlock-keychain',
'-p',
self._keychain_pass,
self._keychain_name,
],
check=True,
capture_output=True,
)
subprocess.run(
[
'security',
'import',
self._signingconfig.certfile,
'-k',
self._keychain_name,
'-f',
'pkcs12',
'-A',
'-T',
'/usr/bin/codesign',
'-T',
'/usr/bin/security',
'-P',
self._signingconfig.certpass,
],
check=True,
capture_output=True,
)
subprocess.run(
[
'security',
'set-key-partition-list',
'-S',
'apple-tool:,apple:',
'-k',
self._keychain_pass,
self._keychain_name,
],
check=True,
capture_output=True,
)
def _tear_down_keychain(self) -> None:
if self._signingconfig is None:
return
# We're mucking with keychain settings here which is a global thing.
# Let's try to at least avoid two of us mucking with it at once.
with self._get_keychain_file_lock():
print('Tearing down signing-config...')
# Grab list of current keychains.
keychains = [
line.strip().replace('"', '')
for line in subprocess.run(
['security', 'list-keychains', '-d', 'user'],
check=True,
capture_output=True,
)
.stdout.decode()
.splitlines()
]
# Strip out ours.
keychains = [k for k in keychains if self._keychain_name not in k]
# Warn if this doesn't put us back to the default 1.
if len(keychains) != 1:
print(
f'{Clr.RED}Expected to restore to 1 keychain;'
f' got {keychains}{Clr.RST}'
)
subprocess.run(
['security', 'list-keychains', '-d', 'user', '-s'] + keychains,
check=True,
capture_output=True,
)
subprocess.run(
['security', 'delete-keychain', self._keychain_name],
check=True,
capture_output=True,
)
@staticmethod
def _argstr(args: list[str], flag: str, remove: bool = False) -> str:
try:
flagindex = args.index(flag)
val = args[flagindex + 1]
if remove:
del args[flagindex : flagindex + 2]
return val
except (ValueError, IndexError) as exc:
raise RuntimeError(f'{flag} value not found') from exc
def _build_cmd_args(self) -> list[str]:
return ['xcodebuild'] + self._args
def _run_cmd(self, cmd: list[str]) -> None:
# reset some state
self._output = []
self._section = None
self._returncode = 0
print(f'{Clr.BLU}Running build: {Clr.BLD}{cmd}{Clr.RST}', flush=True)
with subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
) as proc:
if proc.stdout is None:
raise RuntimeError('Error running command')
while True:
line = proc.stdout.readline().decode()
if len(line) == 0:
break
self._output.append(line)
self._print_filtered_line(line)
proc.wait()
self._returncode = proc.returncode
def _print_filtered_line(self, line: str) -> None:
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
# pylint: disable=too-many-return-statements
# NOTE: xcodebuild output can be coming from multiple tasks and
# intermingled, so lets try to be as conservative as possible when
# hiding lines. When we're not 100% sure we know what a line is,
# we should print it to be sure.
if self._verbose:
sys.stdout.write(line)
sys.stdout.flush()
return
# Look for a few special cases regardless of the section we're in:
if line == '** BUILD SUCCEEDED **\n':
sys.stdout.write(
f'{Clr.GRN}{Clr.BLD}XCODE BUILD SUCCEEDED{Clr.RST}\n'
)
sys.stdout.flush()
return
if line == '** CLEAN SUCCEEDED **\n':
sys.stdout.write(
f'{Clr.GRN}{Clr.BLD}XCODE CLEAN SUCCEEDED{Clr.RST}\n'
)
sys.stdout.flush()
return
if line.startswith(' builtin-ScanDependencies '):
lastbit = line.split('/')[-1].strip()
sys.stdout.write(
f'{Clr.BLU}Scanning Dependencies: {lastbit}{Clr.RST}\n'
)
sys.stdout.flush()
return
if line.startswith('ScanDependencies '):
return
if line.startswith('PrecompileModule '):
# sys.stdout.write('PRECOMPILING\n')
# sys.stdout.flush()
return
if line.startswith(' builtin-precompileModule '):
lastbit = line.split('/')[-1].split('-')[0]
sys.stdout.write(f'{Clr.BLU}Precompiling {lastbit}{Clr.RST}\n')
sys.stdout.flush()
return
# Seeing these popping up in the middle of other stuff a lot.
if any(
line.startswith(x)
for x in [
'SwiftDriver\\ Compilation\\ Requirements ',
'SwiftDriver\\ Compilation ',
' Using response file:',
]
):
return
lsplits = line.split()
if lsplits and lsplits[0] in ['builtin-Swift-Compilation']:
return
# If they're warning us about build phases running every time,
# spit out a simplified warning.
before = "Run script build phase '"
after = "' will be run during every build because"
if before in line and after in line:
phasename = line.split(before)[-1].split(after)[0]
sys.stdout.write(
f"{Clr.WHT}Warning: build phase '{phasename}'"
f' is running every time (no deps set up).{Clr.RST}\n'
)
return
warnstr = 'warning: The Copy Bundle Resources build phase contains'
if warnstr in line:
warnstr2 = line[line.index(warnstr) :].replace(
'warning: ', 'Warning: '
)
sys.stdout.write(f'{Clr.WHT}{warnstr2}{Clr.RST}')
return
# if 'warning: OpenGL is deprecated.' in line:
# return # yes Apple, I know.
# xcodebuild output generally consists of some high level command
# ('CompileC blah blah blah') followed by a number of related lines.
# Look for particular high level commands to switch us into different
# modes.
sectionchanged = False
for section in _Section:
if line.startswith(f'{section.value} '):
self._section = section
sectionchanged = True
if sectionchanged:
self._section_line_count = 0
else:
self._section_line_count += 1
# There's a lot of random chatter at the start of builds,
# so let's go ahead and ignore everything before we've got a
# line-mode set.
if self._section is None:
return
if self._section is _Section.COMPILEC:
self._print_compilec_line(line)
elif self._section is _Section.SWIFTCOMPILE:
self._print_swift_compile_line(line)
elif self._section is _Section.SWIFTMERGEGENERATEDHEADERS:
self._print_simple_section_line(
line, ignore_line_starts=['builtin-swiftHeaderTool']
)
elif self._section is _Section.PRECOMPILESWIFTBRIDGINGHEADER:
self._print_simple_section_line(
line,
prefix='Precompiling Swift Bridging Header',
ignore_line_start_tails=['/swift-frontend'],
)
elif self._section is _Section.SWIFTDRIVER:
self._print_simple_section_line(
line,
ignore_line_starts=[
'builtin-SwiftDriver',
'builtin-Swift-Compilation',
],
)
elif self._section is _Section.CONSTRUCTSTUBEXECUTORLINKFILELIST:
self._print_simple_section_line(
line,
ignore_line_starts=[
'construct-stub-executor-link-file-list',
'note: Using stub executor library with Swift entry point',
],
)
elif self._section is _Section.SWIFTEMITMODULE:
self._print_simple_section_line(
line,
ignore_line_starts=[
'builtin-SwiftDriver',
'builtin-swiftTaskExecution',
'EmitSwiftModule',
],
ignore_line_start_tails=['/swift-frontend'],
)
elif self._section is _Section.SWIFTGENERATEPCH:
self._print_simple_section_line(
line,
ignore_line_starts=['builtin-swiftTaskExecution'],
prefix_unexpected=False,
)
elif self._section is _Section.GENERATEDSYMFILE:
self._print_simple_section_line(
line,
prefix='Generating DSYM File',
ignore_line_start_tails=['/dsymutil'],
)
elif self._section is _Section.SWIFTDRIVERJOBDISCOVERY:
self._print_simple_section_line(
line,
ignore_line_starts=[
'builtin-Swift-Compilation-Requirements',
'builtin-Swift-Compilation',
'builtin-swiftTaskExecution',
'builtin-ScanDependencies',
'/Applications/Xcode.app/Contents/Developer/'
'Toolchains/XcodeDefault.xctoolchain/usr/bin/clang',
],
)
elif self._section is _Section.MKDIR:
self._print_mkdir_line(line)
elif self._section is _Section.LD:
self._print_ld_line(line)
elif self._section is _Section.COMPILEASSETCATALOG:
self._print_compile_asset_catalog_line(line)
elif self._section is _Section.CODESIGN:
self._print_code_sign_line(line)
elif self._section is _Section.COMPILESTORYBOARD:
self._print_compile_storyboard_line(line)
elif self._section is _Section.LINKSTORYBOARDS:
self._print_simple_section_line(
line, ignore_line_start_tails=['/ibtool']
)
elif self._section is _Section.CPRESOURCE:
self._print_simple_section_line(
line, ignore_line_starts=['builtin-copy']
)
elif self._section is _Section.PROCESSINFOPLISTFILE:
self._print_process_info_plist_file_line(line)
elif self._section is _Section.COPYSWIFTLIBS:
self._print_simple_section_line(
line,
ignore_line_starts=['builtin-swiftStdLibTool'],
)
elif self._section is _Section.REGISTEREXECUTIONPOLICYEXCEPTION:
self._print_simple_section_line(
line,
ignore_line_starts=['builtin-RegisterExecutionPolicyException'],
)
elif self._section is _Section.VALIDATE:
self._print_simple_section_line(
line,
ignore_line_starts=['builtin-validationUtility'],
)
elif self._section is _Section.COMPILEXCSTRINGS:
self._print_simple_section_line(
line,
prefix='Compiling strings',
ignore_line_start_tails=['/xcstringstool'],
)
elif self._section is _Section.CONVERTICONSETFILE:
self._print_simple_section_line(
line,
prefix='Creating',
prefix_index=1,
ignore_line_start_tails=['/iconutil'],
)
elif self._section is _Section.TOUCH:
self._print_simple_section_line(
line,
ignore_line_starts=['/usr/bin/touch'],
)
elif self._section is _Section.REGISTERWITHLAUNCHSERVICES:
self._print_simple_section_line(
line, ignore_line_start_tails=['lsregister']
)
elif self._section is _Section.METALLINK:
self._print_simple_section_line(
line,
prefix='Linking',
prefix_index=1,
ignore_line_start_tails=['/metal'],
)
# I think this is outdated and can go away?...
elif self._section is _Section.COMPILESWIFT:
self._print_simple_section_line(
line,
prefix='Compiling',
prefix_index=3,
ignore_line_start_tails=[
'/swift-frontend',
'EmitSwiftModule',
],
)
elif self._section is _Section.CREATEBUILDDIRECTORY:
self._print_simple_section_line(
line,
ignore_line_starts=['builtin-create-build-directory'],
ignore_line_start_tails=['/clang-stat-cache'],
)
elif self._section is _Section.COMPILEMETALFILE:
self._print_simple_section_line(
line,
prefix='Metal-Compiling',
prefix_index=1,
ignore_line_start_tails=['/metal'],
)
elif self._section is _Section.COPY:
self._print_simple_section_line(
line,
ignore_line_starts=['builtin-copy'],
)
elif self._section is _Section.CLANGSTATCACHE:
self._print_simple_section_line(
line,
ignore_line_start_tails=['/clang-stat-cache'],
)
elif self._section is _Section.EXTRACTAPPINTENTSMETADATA:
# Don't think we need to see this.
if 'note: Metadata extraction skipped' in line:
pass
else:
self._print_simple_section_line(
line,
ignore_line_start_tails=['/appintentsmetadataprocessor'],
)
elif self._section is _Section.COPYSTRINGSFILE:
self._print_simple_section_line(
line,
ignore_line_starts=[
'builtin-copyStrings',
'CopyPNGFile',
],
ignore_line_start_tails=[
'/InfoPlist.strings:1:1:',
'/copypng',
'/iconutil',
],
ignore_containing=[
'note: detected encoding of input file as Unicode (UTF-8)'
],
)
elif self._section is _Section.PROCESSPRODUCTPACKAGING:
if '.net.froemling.ballistica.ios"' in line:
return
self._print_simple_section_line(
line,
ignore_line_starts=[
'"application-identifier"',
'"com.apple.developer.ubiquity-kvstore-identifier"',
'"get-task-allow"',
'"keychain-access-groups"',
'builtin-productPackagingUtility',
'Entitlements:',
'{',
'}',
');',
'};',
'"com.apple.security.get-task-allow"'
'"com.apple.security.app-sandbox"',
'"com.apple.Music"',
'"com.apple.Music.library.read"',
'"com.apple.Music.playback"',
'"com.apple.security.app-sandbox"',
'"com.apple.security.automation.apple-events"',
'"com.apple.security.device.bluetooth"',
'"com.apple.security.device.usb"',
'"com.apple.security.get-task-allow"',
'"com.apple.developer.game-center"',
'"com.apple.developer.team-identifier"',
'"com.apple.application-identifier"',
'"com.apple.security.network.client"',
'"com.apple.security.network.server"',
'"com.apple.security.scripting-targets"',
'"com.apple.Music.library.read",',
],
)
elif self._section is _Section.GENERATEASSETSYMBOLS:
self._print_simple_section_line(
line,
ignore_containing=[
'/* com.apple.actool.compilation-results */',
'/GeneratedAssetSymbols-Index.plist',
'/GeneratedAssetSymbols.h',
'/GeneratedAssetSymbols.swift',
],
)
elif self._section is _Section.PROCESSPRODUCTPACKAGINGDER:
self._print_simple_section_line(
line,
ignore_line_start_tails=['/derq'],
)
elif self._section is _Section.WRITEAUXILIARYFILE:
# EW: this spits out our full list of entitlements line by line.
# We should make this smart enough to ignore that whole section
# but just ignoring specific exact lines for now.
self._print_simple_section_line(
line,
ignore_line_starts=[
'PhaseScriptExecution',
'/bin/sh -c',
'write-file',
'builtin-productPackagingUtility',
'ProcessProductPackaging',
],
)
elif self._section is _Section.COMPILESWIFTSOURCES:
self._print_simple_section_line(
line,
prefix='Compiling Swift Sources',
prefix_index=None,
ignore_line_starts=['PrecompileSwiftBridgingHeader'],
ignore_line_start_tails=['/swiftc', '/swift-frontend'],
)
elif self._section is _Section.PROCESSPCH:
self._print_simple_section_line(
line,
ignore_line_starts=['Precompile of'],
ignore_line_start_tails=['/clang'],
)
elif self._section is _Section.PROCESSPCHPLUSPLUS:
self._print_simple_section_line(
line,
ignore_line_starts=['Precompile of'],
ignore_line_start_tails=['/clang'],
)
elif self._section is _Section.PHASESCRIPTEXECUTION:
self._print_simple_section_line(
line,
prefix='Running Script',
prefix_index=1,
ignore_line_starts=['/bin/sh'],
)
# elif self._section is _Section.NOTE:
# self._print_note_line(line)
else:
assert_never(self._section)
sys.stdout.flush()
def _print_compilec_line(self, line: str) -> None:
# TEMP
# sys.stdout.write(line)
# return
# First line of the section.
if self._section_line_count == 0:
# If the file path starts with cwd, strip that out.
fname = shlex.split(line)[2].removeprefix(f'{os.getcwd()}/')
sys.stdout.write(f'{Clr.BLU}Compiling {Clr.BLD}{fname}{Clr.RST}\n')
return
# Ignore empty lines or things we expect to be there.
splits = line.split()
if not splits:
return
if splits[0] in ['cd', 'export']:
return
if splits[0].endswith('/clang'):
return
# Fall back on printing anything we don't recognize.
sys.stdout.write(line)
def _print_swift_compile_line(self, line: str) -> None:
# First line of the section.
if self._section_line_count == 0:
# Currently seeing 2 mostly identical lines per compiled files.
# The first has a 'Compiling\ foo.swift /path/to/foo.swift'
# The second is just /path/to/foo.swift.
# Let's hide the first.
if line.split()[3] == 'Compiling\\':
return
# If the file path starts with cwd, strip that out.
fname = shlex.split(line)[3].removeprefix(f'{os.getcwd()}/')
sys.stdout.write(f'{Clr.BLU}Compiling {Clr.BLD}{fname}{Clr.RST}\n')
return
# Ignore empty lines or things we expect to be there.
splits = line.split()
if not splits:
return
if splits[0] in [
'cd',
'builtin-swiftTaskExecution',
]:
return
if any(splits[0].endswith(s) for s in ['/clang', '/swift-frontend']):
return
# Fall back on printing anything we don't recognize.
sys.stdout.write(line)
def _print_mkdir_line(self, line: str) -> None:
# First line of the section.
if self._section_line_count == 0:
return
# Ignore empty lines or things we expect to be there.
splits = line.split()
if not splits:
return
if splits[0] in ['cd', '/bin/mkdir']:
return
# Fall back on printing anything we don't recognize.
sys.stdout.write(line)
def _print_ld_line(self, line: str) -> None:
# First line of the section.
if self._section_line_count == 0:
name = os.path.basename(shlex.split(line)[1])
sys.stdout.write(f'{Clr.BLU}Linking {Clr.BLD}{name}{Clr.RST}\n')
return
# Ignore empty lines or things we expect to be there.
splits = line.split()
if not splits:
return
if splits[0] in ['cd']:
return
if splits[0].endswith('/clang++'):
return
# Fall back on printing anything we don't recognize.
sys.stdout.write(line)
def _print_compile_asset_catalog_line(self, line: str) -> None:
# pylint: disable=too-many-return-statements
# First line of the section.
if self._section_line_count == 0:
name = os.path.basename(shlex.split(line)[1])
sys.stdout.write(
f'{Clr.BLU}Compiling Asset Catalog {Clr.BLD}{name}{Clr.RST}\n'
)
return
# Ignore empty lines or things we expect to be there.
line_s = line.strip()
splits = line.split()
if not splits:
return
if splits[0] in ['cd']:
return
if splits[0].endswith('/actool'):
return
if line_s == '/* com.apple.actool.compilation-results */':
return
if (
' ibtoold[' in line_s
and 'NSFileCoordinator is doing nothing' in line_s
):
return
if any(line_s.endswith(x) for x in ('.plist', '.icns', '.car')):
return
# Fall back on printing anything we don't recognize.
sys.stdout.write(line)
def _print_compile_storyboard_line(self, line: str) -> None:
# First line of the section.
if self._section_line_count == 0:
name = os.path.basename(shlex.split(line)[1])
sys.stdout.write(
f'{Clr.BLU}Compiling Storyboard {Clr.BLD}{name}{Clr.RST}\n'
)
return
# Ignore empty lines or things we expect to be there.
splits = line.split()
if not splits:
return
if splits[0] in ['cd', 'export']:
return
if splits[0].endswith('/ibtool'):
return
# Fall back on printing anything we don't recognize.
sys.stdout.write(line)
def _print_code_sign_line(self, line: str) -> None:
# pylint: disable=too-many-return-statements
# First line of the section.
if self._section_line_count == 0:
name = os.path.basename(shlex.split(line)[1])
sys.stdout.write(f'{Clr.BLU}Signing' f' {Clr.BLD}{name}{Clr.RST}\n')
return
# Ignore empty lines or things we expect to be there.
splits = line.split()
if not splits:
return
if (
len(splits) > 1
and splits[0] == 'Provisioning'
and splits[1] == 'Profile:'
):
return
# A uuid string (provisioning profile id or whatnot)
if (
len(splits) == 1
and splits[0].startswith('(')
and splits[0].endswith(')')
and len(splits[0].split('-')) == 5
):
return
if splits[0] in ['cd', 'export', '/usr/bin/codesign']:
return
if line.strip().startswith('Signing Identity:'):
return
if ': replacing existing signature' in line:
return
# Fall back on printing anything we don't recognize.
sys.stdout.write(line)
def _print_process_info_plist_file_line(self, line: str) -> None:
# First line of the section.
if self._section_line_count == 0:
name = os.path.basename(shlex.split(line)[1])
sys.stdout.write(f'{Clr.BLU}Processing {Clr.BLD}{name}{Clr.RST}\n')
return
# Ignore empty lines or things we expect to be there.
splits = line.split()
if not splits:
return
if splits[0] in ['cd', 'export', 'builtin-infoPlistUtility']:
return
# Fall back on printing anything we don't recognize.
sys.stdout.write(line)
def _print_simple_section_line(
self,
line: str,
prefix: str | None = None,
prefix_index: int | None = None,
ignore_line_starts: list[str] | None = None,
ignore_line_start_tails: list[str] | None = None,
ignore_containing: list[str] | None = None,
prefix_unexpected: bool = True,
) -> None:
# pylint: disable=too-many-branches
# pylint: disable=too-many-positional-arguments
if ignore_line_starts is None:
ignore_line_starts = []
if ignore_line_start_tails is None:
ignore_line_start_tails = []
if ignore_containing is None:
ignore_containing = []
# First line of the section.
if self._section_line_count == 0:
if prefix is not None:
if prefix_index is None:
sys.stdout.write(f'{Clr.BLU}{prefix}{Clr.RST}\n')
else:
name = os.path.basename(shlex.split(line)[prefix_index])
sys.stdout.write(
f'{Clr.BLU}{prefix}' f' {Clr.BLD}{name}{Clr.RST}\n'
)
return
# Ignore empty lines or things we expect to be there.
splits = line.split()
if not splits:
return
for start in ['cd', 'export'] + ignore_line_starts:
# The start strings they pass may themselves be splittable so
# we may need to compare more than one string.
startsplits = start.split()
if splits[: len(startsplits)] == startsplits:
return
if any(splits[0].endswith(tail) for tail in ignore_line_start_tails):
return
if any(c in line for c in ignore_containing):
return
# Fall back on printing anything we don't recognize.
if prefix is None and prefix_unexpected:
# If a prefix was not supplied for this section, the user will
# have no way to know what this output relates to. Tack a bit
# on to clarify in that case (unless requested not to).
assert self._section is not None
sys.stdout.write(
f'{Clr.YLW}Unfiltered Output (Section {self._section.value}):'
f'{Clr.RST} {line}'
)
else:
sys.stdout.write(line)
[docs]
def project_build_path(
projroot: str,
project_path: str,
scheme: str,
configuration: str,
executable: bool = True,
) -> str:
"""Get build paths for an xcode project (cached for efficiency)."""
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
config_path = os.path.join(projroot, '.cache', 'xcode_build_path')
config: dict[str, dict[str, Any]] = {}
build_dir: str | None = None
executable_path: str | None = None
if os.path.exists(config_path):
with open(config_path, encoding='utf-8') as infile:
config = json.loads(infile.read())
if (
project_path in config
and configuration in config[project_path]
and scheme in config[project_path][configuration]
):
# Ok we've found a build-dir entry for this project; now if it
# exists on disk and all timestamps within it are decently
# close to the one we've got recorded, lets use it.
# (Anything using this script should also be building
# stuff there so mod times should be pretty recent; if not
# then its worth re-caching to be sure.)
cached_build_dir = config[project_path][configuration][scheme][
'build_dir'
]
cached_timestamp = config[project_path][configuration][scheme][
'timestamp'
]
cached_executable_path = config[project_path][configuration][
scheme
]['executable_path']
assert isinstance(cached_build_dir, str)
assert isinstance(cached_timestamp, float)
assert isinstance(cached_executable_path, str)
now = time.time()
if (
os.path.isdir(cached_build_dir)
and abs(now - cached_timestamp) < 60 * 60 * 24
):
build_dir = cached_build_dir
executable_path = cached_executable_path
# If we don't have a path at this point we look it up and cache it.
if build_dir is None:
print('Caching xcode build path...', file=sys.stderr)
cmd = [
'xcodebuild',
'-project',
project_path,
'-showBuildSettings',
'-configuration',
configuration,
'-scheme',
scheme,
]
output = subprocess.run(
cmd, check=True, capture_output=True
).stdout.decode()
prefix = 'TARGET_BUILD_DIR = '
lines = [l for l in output.splitlines() if l.strip().startswith(prefix)]
if len(lines) != 1:
raise RuntimeError(
'TARGET_BUILD_DIR not found in xcodebuild settings output.'
)
build_dir = lines[0].replace(prefix, '').strip()
prefix = 'EXECUTABLE_PATH = '
lines = [l for l in output.splitlines() if l.strip().startswith(prefix)]
if len(lines) != 1:
raise RuntimeError(
'EXECUTABLE_PATH not found in xcodebuild settings output.'
)
executable_path = lines[0].replace(prefix, '').strip()
if project_path not in config:
config[project_path] = {}
if configuration not in config[project_path]:
config[project_path][configuration] = {}
config[project_path][configuration][scheme] = {
'build_dir': build_dir,
'executable_path': executable_path,
'timestamp': time.time(),
}
os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as outfile:
outfile.write(json.dumps(config))
assert build_dir is not None
if executable:
assert executable_path is not None
outpath = os.path.join(build_dir, executable_path)
if not os.path.isfile(outpath):
raise RuntimeError(f'Path is not a file: "{outpath}".')
else:
outpath = build_dir
if not os.path.isdir(outpath):
raise RuntimeError(f'Path is not a dir: "{outpath}".')
return outpath