# Released under the MIT License. See LICENSE for details.
#
"""XCode related functionality."""
from __future__ import annotations
import os
import hashlib
from typing import TYPE_CHECKING
import openstep_parser as osp
from pbxproj import XcodeProject
from pbxproj.pbxextensions import TreeType, PBXGroup
# Need to patch XcodeProject slightly to support .cc files.
# noinspection PyProtectedMember
xcft = XcodeProject._FILE_TYPES # pylint: disable=protected-access
if '.cc' not in xcft:
xcft['.cc'] = xcft['.cpp']
# Normally header files are added to a copy-headers build phase;
# we don't want that (its only really relevant for frameworks and
# its something else we'd need to worry about restoring uuids for).
xcft['.h'] = (xcft['.h'][0], None)
if TYPE_CHECKING:
from typing import Any
[docs]
def update_xcode_project(
projroot: str,
path: str,
existing_data: str,
all_source_files: list[str],
projname: str,
force: bool = False,
) -> str:
"""Given an xcode project, update it for the current set of files."""
# pylint: disable=too-many-positional-arguments
suffixes = ['.cc', '.h', '.m', '.mm', '.swift']
updater = Updater(
projroot,
path,
existing_data,
sorted(
p for p in all_source_files if os.path.splitext(p)[1] in suffixes
),
# has_app_delegate_mm=True,
projname=projname,
)
return updater.run(force=force)
[docs]
class Updater:
"""Does the thing."""
project: Any
def __init__(
self,
projroot: str,
path: str,
existing_data: str,
sources: list[str],
projname: str,
) -> None:
# pylint: disable=too-many-positional-arguments
if not path.endswith('.xcodeproj'):
raise RuntimeError(f"Path does not end in .xcodeproj: '{path}'.")
self.projroot = projroot
self.path = path
self.existing_data = existing_data
self.sources = sources
self.project = None
# Project name variations.
self.pnameu = projname
self.pnamel = projname.lower()
# uuids associated with a given file path
# (grp/file obj and possibly build-files)
self.old_path_uuids: dict[str, list[str]] = {}
self.new_path_uuids: dict[str, list[str]] = {}
self.print_test = False
[docs]
def run(self, force: bool = False) -> str:
"""Do the thing."""
# pylint: disable=too-many-locals
projpath = os.path.join(self.projroot, self.path, 'project.pbxproj')
# Make a hash out of all paths we'd add combined with the incoming
# state of this project. If this calced hash matches the current
# on-disk hash, we can assume our output would match the input
# and just deliver the input.
projsrc = self.existing_data
fhash = self.hash_inputs(
self.sources, include_us=True, project_source=projsrc
)
# WARNING - this cache naming convention assumes all project
# basenames are unique regardless of dir (currently true but
# maybe won't always be).
_dirname, basename = os.path.split(self.path)
basebasename = os.path.splitext(basename)[0]
cachedir = os.path.join(self.projroot, '.cache')
os.makedirs(cachedir, exist_ok=True)
hashpath = os.path.join(cachedir, f'xcode_src_hash_{basebasename}')
if os.path.exists(hashpath):
with open(hashpath, encoding='utf-8') as infile:
currenthash = infile.read()
else:
currenthash = None
# If hash still matches, return incoming project state.
if currenthash == fhash and not force:
# with open(projpath, encoding='utf-8') as infile:
# existing_file = infile.read()
# FIXME: Its weird to be feeding our hash file back out to
# the project system; we should probably just manage it
# completely internally.
return self.existing_data
tree = osp.OpenStepDecoder.ParseFromString(self.existing_data)
self.project = XcodeProject(tree, projpath)
# self.project = XcodeProject.load(projpath)
bgrp = self._get_unique_group('ballistica')
assert isinstance(bgrp.get_id(), str)
# Store uuids for all existing paths under ballistica and then
# blow it away. When we're done rebuilding ballistica we'll
# restore uuids on any paths that got remade. This keeps changes
# to the pbxproj file much more minimal which is good for git,
# and is simpler to accomplish than doing more selective
# adds/removes would be.
self._store_path_uuids(bgrp.get_id(), self.old_path_uuids, '')
self.project.remove_group_by_id(bgrp.get_id())
srcgrp = self._get_unique_group(f'{self.pnameu} Shared')
self.add_paths(srcgrp)
# if self.has_app_delegate_mm:
# self.mod_app_delegate_mm()
# Groups we made should be sorted already since we sorted while
# building them, but let's sort the top level group we placed
# our stuff *into*.
srcgrp.children.sort(
key=lambda c: self.project.objects[c].get_name().lower()
)
# Now store uuids for the new stuff we made.
bgrp = self._get_unique_group('ballistica')
assert isinstance(bgrp.get_id(), str)
self._store_path_uuids(bgrp.get_id(), self.new_path_uuids, '')
# Now filter the raw project file to replace new uuids with old
# ones when possible.
self._filter_uuids(projpath)
# Now go through and, for every object with a path equal to its
# name, kill the name. This seems to match what xcode does so our
# project structure stays more similar to theirs.
bgrp = self._get_unique_group('ballistica')
assert isinstance(bgrp.get_id(), str)
self._trim_names(bgrp.get_id(), '')
projsrc = repr(self.project) + '\n'
# A few hacky last tweaks on the final project source to
# get ours matching xcode's 100% when possible.
projsrc = projsrc.replace(
'/* Build configuration list for PBXProject'
f' "{self.pnameu} macOS Legacy" */',
'/* Build configuration list for PBXProject'
f' "{self.pnamel}-mac" */',
)
# The hash we generated above used the project as it exists on disk
# for checking purposes, so for the one we return we need to
# regenerate it here to use the project source we just created.
fhash = self.hash_inputs(
self.sources, include_us=True, project_source=projsrc
)
# Store the new hash.
if fhash != currenthash:
with open(hashpath, 'w', encoding='utf-8') as outfile:
outfile.write(fhash)
return projsrc
def _target_name_for_buildfile(self, buildfile: Any) -> str:
for target in self.project.objects.get_targets():
for build_phase_id in target.buildPhases:
build_phase = self.project.objects[build_phase_id]
if build_phase.isa == 'PBXSourcesBuildPhase':
if buildfile.get_id() in build_phase.files:
assert isinstance(target.name, str)
return target.name
raise RuntimeError(
f'Could not deduce target name from build file {buildfile}.'
)
def _filter_uuids(self, projpath: str) -> None:
projtxt = repr(self.project) + '\n'
# For any path that used to exist in our project, if we
# find a new uuid for it, replace it with the old one.
for oldpath, olduuids in self.old_path_uuids.items():
newuuids = self.new_path_uuids.get(oldpath)
if newuuids is not None:
if len(olduuids) != len(newuuids):
print(
f'uuids count changed for path {oldpath}; unexpected.'
)
else:
for olduuid, newuuid in zip(olduuids, newuuids):
projtxt = projtxt.replace(newuuid, olduuid)
# Now replace our existing project with this filtered one.
# This will properly update ordering for the id swaps we just made.
tree = osp.OpenStepDecoder.ParseFromString(projtxt)
self.project = XcodeProject(tree, projpath)
def _trim_names(self, objid: str, parentpath: str) -> None:
obj = self.project.objects[objid]
assert hasattr(obj, 'name')
objpath = os.path.join(parentpath, obj.name)
if isinstance(obj, PBXGroup):
for childid in obj.children:
self._trim_names(childid, objpath)
assert hasattr(obj, 'path')
if obj.path == obj.name:
delattr(obj, 'name')
def _store_path_uuids(
self, objid: str, uuids: dict[str, list[str]], parentpath: str
) -> None:
obj = self.project.objects[objid]
# Hmmm - seems sometimes things have name but sometimes just path.
# (assuming maybe when they're identical or something?..)
if hasattr(obj, 'name'):
objpath = os.path.join(parentpath, obj.name)
else:
assert hasattr(obj, 'path')
assert '/' not in obj.path
objpath = os.path.join(parentpath, obj.path)
# Store this uuid with this path.
uuidentry = uuids.setdefault(objpath, [])
uuidentry.append(objid)
if isinstance(obj, PBXGroup):
for childid in obj.children:
self._store_path_uuids(childid, uuids, objpath)
else:
buildfiles = self.project.get_build_files_for_file(objid)
# We'll replace the new buildfile ids with our previous ones;
# however can come out shuffled (the buildfile for target A
# might be given the UUID that was previously assigned to the
# buildfile for target B, etc.) We can fix this by sorting
# our buildfiles by the target they go with.
buildfiles.sort(key=self._target_name_for_buildfile)
for buildfile in buildfiles:
uuidentry.append(buildfile.get_id())
def _get_unique_group(self, name: str) -> Any:
grps = self.project.get_groups_by_name(name)
if len(grps) != 1:
raise RuntimeError(
f'Expected exactly 1 "{name}" group; found {len(grps)}.'
)
return grps[0]
# (No longer used; just leaving here as reference though)
[docs]
def mod_app_delegate_mm(self) -> None:
"""Set per-file compiler flags."""
files = self.project.get_files_by_name('app_delegate.mm')
if len(files) != 1:
# Update: no longer expecting to always find this now that
# it has been moved to base.
if self.pnameu == 'Ballistica' + 'Kit':
raise RuntimeError(
f'Expected to find exactly 1 app_delegate.mm;'
f' found {len(files)}.'
)
else:
bfiles = self.project.get_build_files_for_file(files[0].get_id())
for bfile in bfiles:
bfile.add_compiler_flags('-fobjc-arc')
@staticmethod
def _xcodesortkey(val: str) -> str:
# Yes this is super nitpicky, but I'd like to have things
# show up in xcode such that doing a 'sort by name' doesn't move
# anything around. The main funky bit of logic with xcode's
# sorting seems to be that foo.cc shows up *after* foobar.cc,
# whereas in vanilla Python sorts it comes before. A quick hack
# to fix this is to replace the . with something that comes after
# the alphabet instead of before.
return val.lower().replace('.', '~')
[docs]
def add_paths(self, parent_pbxgrp: Any) -> None:
"""Do the thing."""
# PBXGroups we create for each dir we come across in paths.
dir_pbxgrps: dict[str, Any] = {}
# For each path, create/fetch its chain of dirs as PBXGroups and
# drop the file in the bottom one.
for source in self.sources:
parts = source.split('/')
assert all(p for p in parts)
for i in range(len(parts) - 1):
thisname = parts[i]
thispath = '/'.join(parts[: i + 1])
if thispath not in dir_pbxgrps:
if i == 0:
# Root; provide full path.
pbxgrp = self.project.get_or_create_group(
thisname,
os.path.abspath(
os.path.join(self.projroot, 'src', thispath)
),
parent_pbxgrp,
make_relative=True,
)
else:
# Non-root; provide relative path from parent.
parentpath = '/'.join(parts[:i])
pbxgrp = self.project.get_or_create_group(
thisname,
thisname,
dir_pbxgrps[parentpath],
make_relative=True,
)
dir_pbxgrps[thispath] = pbxgrp
assert os.path.dirname(source) # All sources should be in a dir.
self.project.add_file(
os.path.basename(source),
dir_pbxgrps[os.path.dirname(source)],
force=False,
tree=TreeType.GROUP,
target_name=self._target_names_for_file(
os.path.basename(source)
),
)
def _target_names_for_file(self, filename: str) -> list[str] | None:
# Cocoa stuff only applies to our macOS targets.
if filename.startswith('Cocoa') and filename.endswith('.swift'):
return [
f'{self.pnameu} macOS TestBuild',
f'{self.pnameu} macOS AppStore',
f'{self.pnameu} macOS Steam',
]
# A few things only for AppStore bound builds.
if filename in {'StoreKitContext.swift', 'GameCenterContext.swift'}:
return [
f'{self.pnameu} iOS',
f'{self.pnameu} tvOS',
f'{self.pnameu} macOS AppStore',
]
# UIKit stuff applies to our iOS/tvOS targets.
if filename.startswith('UIKit') and filename.endswith('.swift'):
return [
f'{self.pnameu} iOS',
f'{self.pnameu} tvOS',
]
# Everything else applies to everything.
return None
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum