mirror of
https://github.com/RYDE-WORK/ballistica.git
synced 2026-01-19 21:37:57 +08:00
1130 lines
41 KiB
Python
1130 lines
41 KiB
Python
# 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
|
|
|
|
|
|
@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'
|
|
SWIFTEMITMODULE = 'SwiftEmitModule'
|
|
COMPILESWIFT = 'CompileSwift'
|
|
MKDIR = 'MkDir'
|
|
LD = 'Ld'
|
|
CPRESOURCE = 'CpResource'
|
|
COMPILEASSETCATALOG = 'CompileAssetCatalog'
|
|
CODESIGN = 'CodeSign'
|
|
COMPILESTORYBOARD = 'CompileStoryboard'
|
|
CONVERTICONSETFILE = 'ConvertIconsetFile'
|
|
LINKSTORYBOARDS = 'LinkStoryboards'
|
|
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'
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
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:
|
|
# 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
|
|
|
|
# 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 ',
|
|
]
|
|
):
|
|
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.SWIFTDRIVER:
|
|
self._print_simple_section_line(
|
|
line,
|
|
ignore_line_starts=[
|
|
'builtin-SwiftDriver',
|
|
'builtin-Swift-Compilation',
|
|
],
|
|
)
|
|
elif self._section is _Section.SWIFTEMITMODULE:
|
|
self._print_simple_section_line(
|
|
line,
|
|
ignore_line_starts=[
|
|
'builtin-SwiftDriver',
|
|
'builtin-swiftTaskExecution',
|
|
],
|
|
)
|
|
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',
|
|
],
|
|
)
|
|
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
|
|
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)
|
|
|
|
|
|
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
|