# Copyright (c) 2011-2020 Eric Froemling # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # ----------------------------------------------------------------------------- """Tools related to ios development.""" from __future__ import annotations import pathlib import subprocess import sys from dataclasses import dataclass from efrotools import get_localconfig, get_config MODES = { 'debug': { 'configuration': 'Debug' }, 'release': { 'configuration': 'Release' } } @dataclass class Config: """Configuration values for this project.""" # 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 @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 push_ipa(root: pathlib.Path, modename: str) -> 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. """ # Load both the local and project config data. cfg = Config(**get_config(root)['push_ipa_config']) lcfg = LocalConfig(**get_localconfig(root)['push_ipa_local_config']) if modename not in MODES: raise Exception('invalid mode: "' + str(modename) + '"') mode = MODES[modename] xc_build_path = pathlib.Path(root, 'tools/xc_build_path') xcprojpath = pathlib.Path(root, cfg.projectpath) app_dir = subprocess.run( [xc_build_path, xcprojpath, mode['configuration']], check=True, capture_output=True).stdout.decode().strip() built_app_path = pathlib.Path(app_dir, cfg.app_bundle_name) 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) # Export an IPA from said xcarchive. ipa_path = _export_ipa_from_xcarchive(archivepath, exportoptionspath, ipa_dir_path, cfg) # 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) -> pathlib.Path: archivepathbase = pathlib.Path(workdir, cfg.archive_name) archivepath = pathlib.Path(workdir, cfg.archive_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 = [ 'xcodebuild', 'archive', '-project', str(xcprojpath), '-scheme', cfg.scheme, '-configuration', MODES['debug']['configuration'], '-archivePath', str(archivepathbase) ] subprocess.run(args, check=True, capture_output=True) # Now copy our just-built app into the archive. print('Copying build to archive...') sys.stdout.flush() archive_app_path = pathlib.Path( archivepath, 'Products/Applications/' + cfg.app_bundle_name) 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) -> pathlib.Path: import textwrap print('Exporting IPA...') exportoptions = textwrap.dedent(""" compileBitcode destination export method development signingStyle automatic stripSwiftSymbols teamID G7TQB7SM63 thinning <none> """).strip() with exportoptionspath.open('w') as outfile: outfile.write(exportoptions) sys.stdout.flush() args = [ 'xcodebuild', '-allowProvisioningUpdates', '-exportArchive', '-archivePath', str(archivepath), '-exportOptionsPlist', str(exportoptionspath), '-exportPath', str(ipa_dir_path) ] 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.scheme + '.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