# Released under the MIT License. See LICENSE for details.
#
"""Tools related to ios development."""
from __future__ import annotations
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
[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.
This takes some shortcuts to minimize turnaround time;
It doesn't recreate the ipa completely each run, uses rsync
for speedy pushes to the staging server, etc.
The use case for this is quick build iteration on a device
that is not physically near the build machine.
"""
from efrotools.xcodebuild import project_build_path
# Load both the local and project config data.
# FIXME: switch this to use dataclassio.
cfg = Config(**getprojectconfig(root)['push_ipa_config'])
lcfg = LocalConfig(**getlocalconfig(root)['push_ipa_local_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
)
# And lastly sync said 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!')
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