# Released under the MIT License. See LICENSE for details.
#
"""Tools related to ios development."""
from __future__ import annotations # Docs-generation hack.
import pathlib
import subprocess
import sys
from dataclasses import dataclass
from efrotools.project import getprojectconfig, getlocalconfig
MODES = {
'debug': {'configuration': 'Debug'},
'release': {'configuration': 'Release'},
}
[docs]
@dataclass
class Config:
"""Configuration values for this project."""
# Same as XCode setting.
product_name: str
# Project relative xcodeproj path ('MyAppName/MyAppName.xcodeproj').
projectpath: str
# App bundle name ('MyAppName.app').
# app_bundle_name: str
# Base name of the ipa archive to be pushed ('myappname').
# archive_name: str
# Scheme to build ('MyAppName iOS').
scheme: str
[docs]
@dataclass
class LocalConfig:
"""Configuration values specific to the machine."""
# Sftp host ('myuserid@myserver.com').
sftp_host: str
# Path to push ipa to ('/home/myhome/dir/where/i/want/this/).
sftp_dir: str
def _construct_ipa(
root: pathlib.Path, modename: str, signing_config: str | None
) -> tuple[pathlib.Path, pathlib.Path]:
"""Construct an iOS IPA, recycling the xcarchive across runs.
Returns an ``(ipa_path, built_app_path)`` pair. Shared by both the
staging-server push and the archive push.
This takes some shortcuts to minimize turnaround time; it doesn't
recreate the xcarchive completely each run, etc. The use case is
quick build iteration on a device that is not physically near the
build machine.
"""
from efrotools.xcodebuild import project_build_path
# Load project config data.
# FIXME: switch this to use dataclassio.
cfg = Config(**getprojectconfig(root)['push_ipa_config'])
if modename not in MODES:
raise RuntimeError(f'invalid mode: "{modename}"')
mode = MODES[modename]
xcprojpath = pathlib.Path(root, cfg.projectpath)
app_dir = project_build_path(
projroot=str(root),
project_path=str(xcprojpath),
scheme=cfg.scheme,
configuration=mode['configuration'],
executable=False,
)
built_app_path = pathlib.Path(app_dir, f'{cfg.product_name}.app')
workdir = pathlib.Path(root, 'build', 'push_ipa')
workdir.mkdir(parents=True, exist_ok=True)
pathlib.Path(root, 'build').mkdir(parents=True, exist_ok=True)
exportoptionspath = pathlib.Path(root, workdir, 'exportoptions.plist')
ipa_dir_path = pathlib.Path(root, workdir, 'ipa')
ipa_dir_path.mkdir(parents=True, exist_ok=True)
# Inject our latest build into an existing xcarchive (creating if needed).
archivepath = _add_build_to_xcarchive(
workdir, xcprojpath, built_app_path, cfg, signing_config
)
# Export an IPA from said xcarchive.
ipa_path = _export_ipa_from_xcarchive(
archivepath, exportoptionspath, ipa_dir_path, cfg, signing_config
)
return ipa_path, built_app_path
[docs]
def push_ipa(
root: pathlib.Path, modename: str, signing_config: str | None
) -> None:
"""Construct ios IPA and push it to staging server for device testing.
Uses rsync for speedy pushes to the staging server. See
:func:`push_ipa_to_archive` for the newer archive-based path.
"""
# Load machine-specific config (staging-server destination).
# FIXME: switch this to use dataclassio.
lcfg = LocalConfig(**getlocalconfig(root)['push_ipa_local_config'])
ipa_path, _built_app_path = _construct_ipa(root, modename, signing_config)
# Sync the IPA up to our staging server.
print('Pushing to staging server...')
sys.stdout.flush()
subprocess.run(
[
'rsync',
'--verbose',
ipa_path,
'-e',
'ssh -oBatchMode=yes -oStrictHostKeyChecking=yes',
f'{lcfg.sftp_host}:{lcfg.sftp_dir}',
],
check=True,
)
print('iOS Package Updated Successfully!')
[docs]
def push_ipa_to_archive(
root: pathlib.Path,
modename: str,
signing_config: str | None,
archive_id: str,
) -> None:
"""Construct an IPA and publish it as a new bamaster archive version.
Replaces the staging-server rsync with an upload into the archive
system (content-addressed GCS storage, via ``bacloud admin archive
publish``). The master server's Installs page serves the latest
version to iOS via a short-lived signed URL wrapped in an OTA
manifest. A small ``install_meta.json`` sidecar carrying bundle
metadata is published alongside the IPA so the manifest can be built
without re-parsing the binary.
"""
import json
import shutil
import plistlib
import tempfile
ipa_path, built_app_path = _construct_ipa(root, modename, signing_config)
# Pull bundle metadata out of the freshly built app for the manifest.
with pathlib.Path(built_app_path, 'Info.plist').open('rb') as infile:
info = plistlib.load(infile)
meta = {
'bundle_id': info.get('CFBundleIdentifier', ''),
'bundle_version': info.get('CFBundleVersion', ''),
'bundle_short_version': info.get('CFBundleShortVersionString', ''),
'title': (
info.get('CFBundleDisplayName')
or info.get('CFBundleName')
or 'BallisticaKit'
),
}
# Stage the IPA + sidecar in a clean dir and publish them together as
# one version. We omit the version arg so the archive system
# auto-assigns the next integer after the latest published version.
with tempfile.TemporaryDirectory() as tmpdir:
shutil.copy2(ipa_path, pathlib.Path(tmpdir, ipa_path.name))
with pathlib.Path(tmpdir, 'install_meta.json').open(
'w', encoding='utf-8'
) as outfile:
json.dump(meta, outfile)
print(f'Publishing {archive_id} to archive...')
sys.stdout.flush()
subprocess.run(
[
str(pathlib.Path(root, 'tools', 'bacloud')),
'admin',
'archive',
'publish',
archive_id,
tmpdir,
],
check=True,
)
print('iOS build published to archive successfully!')
def _add_build_to_xcarchive(
workdir: pathlib.Path,
xcprojpath: pathlib.Path,
built_app_path: pathlib.Path,
cfg: Config,
ba_signing_config: str | None,
) -> pathlib.Path:
archivepathbase = pathlib.Path(workdir, cfg.product_name)
archivepath = pathlib.Path(workdir, cfg.product_name + '.xcarchive')
# Rebuild a full archive if one doesn't exist.
if not archivepath.exists():
print('Base archive not found; doing full build (can take a while)...')
sys.stdout.flush()
args = [
'tools/pcommand',
'xcodebuild',
'archive',
'-project',
str(xcprojpath),
'-scheme',
cfg.scheme,
'-configuration',
MODES['debug']['configuration'],
'-archivePath',
str(archivepathbase),
'-allowProvisioningUpdates',
]
if ba_signing_config is not None:
args += ['-baSigningConfig', ba_signing_config]
subprocess.run(args, check=True, capture_output=False)
# Now copy our just-built app into the archive.
print('Copying build to archive...')
sys.stdout.flush()
archive_app_path = pathlib.Path(
archivepath, f'Products/Applications/{cfg.product_name}.app'
)
subprocess.run(['rm', '-rf', archive_app_path], check=True)
subprocess.run(['cp', '-r', built_app_path, archive_app_path], check=True)
return archivepath
def _export_ipa_from_xcarchive(
archivepath: pathlib.Path,
exportoptionspath: pathlib.Path,
ipa_dir_path: pathlib.Path,
cfg: Config,
signing_config: str | None,
) -> pathlib.Path:
import textwrap
print('Exporting IPA...')
exportoptions = textwrap.dedent("""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>compileBitcode</key>
<false/>
<key>destination</key>
<string>export</string>
<key>method</key>
<string>development</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>G7TQB7SM63</string>
<key>thinning</key>
<string><none></string>
</dict>
</plist>
""").strip()
with exportoptionspath.open('w') as outfile:
outfile.write(exportoptions)
sys.stdout.flush()
args = [
'tools/pcommand',
'xcodebuild',
'-allowProvisioningUpdates',
'-exportArchive',
'-archivePath',
str(archivepath),
'-exportOptionsPlist',
str(exportoptionspath),
'-exportPath',
str(ipa_dir_path),
]
if signing_config is not None:
args += ['-baSigningConfig', signing_config]
try:
subprocess.run(args, check=True, capture_output=True)
except Exception:
print(
'Error exporting code-signed archive; '
' perhaps try running "security unlock-keychain login.keychain"'
)
raise
ipa_path_exported = pathlib.Path(ipa_dir_path, cfg.product_name + '.ipa')
# ipa_path = pathlib.Path(ipa_dir_path, cfg.archive_name + '.ipa')
# subprocess.run(['mv', ipa_path_exported, ipa_path], check=True)
return ipa_path_exported
# 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