'''
iOS target, based on kivy-ios project
'''
import sys
import plistlib
from buildozer import BuildozerCommandException
from buildozer.target import Target, no_config
from os.path import join, basename, expanduser, realpath
from getpass import getpass
PHP_TEMPLATE = '''
Install {appname}
'''
class TargetIos(Target):
targetname = "ios"
def __init__(self, buildozer):
super().__init__(buildozer)
executable = sys.executable or 'python'
self._toolchain_cmd = f"{executable} toolchain.py "
self._xcodebuild_cmd = "xcodebuild "
# set via install_platform()
self.ios_dir = None
self.ios_deploy_dir = None
def check_requirements(self):
if sys.platform != "darwin":
raise NotImplementedError("Only macOS is supported for iOS target")
checkbin = self.buildozer.checkbin
cmd = self.buildozer.cmd
checkbin('Xcode xcodebuild', 'xcodebuild')
checkbin('Xcode xcode-select', 'xcode-select')
checkbin('Git git', 'git')
checkbin('Cython cython', 'cython')
checkbin('pkg-config', 'pkg-config')
checkbin('autoconf', 'autoconf')
checkbin('automake', 'automake')
checkbin('libtool', 'libtool')
self.buildozer.debug('Check availability of a iPhone SDK')
sdk = cmd('xcodebuild -showsdks | fgrep "iphoneos" |'
'tail -n 1 | awk \'{print $2}\'',
get_stdout=True)[0]
if not sdk:
raise Exception(
'No iPhone SDK found. Please install at least one iOS SDK.')
else:
self.buildozer.debug(' -> found %r' % sdk)
self.buildozer.debug('Check Xcode path')
xcode = cmd('xcode-select -print-path', get_stdout=True)[0]
if not xcode:
raise Exception('Unable to get xcode path')
self.buildozer.debug(' -> found {0}'.format(xcode))
def install_platform(self):
"""
Clones `kivy/kivy-ios` and `phonegap/ios-deploy` then sets `ios_dir`
and `ios_deploy_dir` accordingly.
"""
self.ios_dir = self.install_or_update_repo('kivy-ios', platform='ios')
self.ios_deploy_dir = self.install_or_update_repo('ios-deploy',
platform='ios',
branch='1.7.0',
owner='phonegap')
def toolchain(self, cmd, **kwargs):
kwargs.setdefault('cwd', self.ios_dir)
return self.buildozer.cmd(self._toolchain_cmd + cmd, **kwargs)
def xcodebuild(self, *args, **kwargs):
return self.buildozer.cmd(self._xcodebuild_cmd + ' '.join(arg for arg in args if arg is not None), **kwargs)
@property
def code_signing_allowed(self):
allowed = self.buildozer.config.getboolean("app", "ios.codesign.allowed")
allowed = "YES" if allowed else "NO"
return f"CODE_SIGNING_ALLOWED={allowed}"
@property
def code_signing_development_team(self):
team = self.buildozer.config.getdefault("app", f"ios.codesign.development_team.{self.build_mode}", None)
return f"DEVELOPMENT_TEAM={team}" if team else None
def get_available_packages(self):
available_modules = self.toolchain("recipes --compact", get_stdout=True)[0]
return available_modules.splitlines()[0].split()
def load_plist_from_file(self, plist_rfn):
with open(plist_rfn, 'rb') as f:
return plistlib.load(f)
def dump_plist_to_file(self, plist, plist_rfn):
with open(plist_rfn, 'wb') as f:
plistlib.dump(plist, f)
def compile_platform(self):
# for ios, the compilation depends really on the app requirements.
# compile the distribution only if the requirements changed.
last_requirements = self.buildozer.state.get('ios.requirements', '')
app_requirements = self.buildozer.config.getlist('app', 'requirements',
'')
# we need to extract the requirements that kivy-ios knows about
available_modules = self.get_available_packages()
onlyname = lambda x: x.split('==')[0] # noqa: E731 do not assign a lambda expression, use a def
ios_requirements = [x for x in app_requirements if onlyname(x) in
available_modules]
need_compile = 0
if last_requirements != ios_requirements:
need_compile = 1
# len('requirements.source.') == 20, so use name[20:]
source_dirs = {'{}_DIR'.format(name[20:].upper()):
realpath(expanduser(value))
for name, value in self.buildozer.config.items('app')
if name.startswith('requirements.source.')}
if source_dirs:
need_compile = 1
self.buildozer.environ.update(source_dirs)
self.buildozer.info('Using custom source dirs:\n {}'.format(
'\n '.join(['{} = {}'.format(k, v)
for k, v in source_dirs.items()])))
if not need_compile:
self.buildozer.info('Distribution already compiled, pass.')
return
modules_str = ' '.join(ios_requirements)
self.toolchain(f"build {modules_str}")
if not self.buildozer.file_exists(self.ios_deploy_dir, 'ios-deploy'):
self.xcodebuild(cwd=self.ios_deploy_dir)
self.buildozer.state['ios.requirements'] = ios_requirements
self.buildozer.state.sync()
def _get_package(self):
config = self.buildozer.config
package_domain = config.getdefault('app', 'package.domain', '')
package = config.get('app', 'package.name')
if package_domain:
package = package_domain + '.' + package
return package.lower()
def build_package(self):
self._unlock_keychain()
# create the project
app_name = self.buildozer.namify(self.buildozer.config.get('app',
'package.name'))
ios_frameworks = self.buildozer.config.getlist('app', 'ios.frameworks', '')
frameworks_cmd = ''
for framework in ios_frameworks:
frameworks_cmd += '--add-framework={} '.format(framework)
self.app_project_dir = join(self.ios_dir, '{0}-ios'.format(app_name.lower()))
if not self.buildozer.file_exists(self.app_project_dir):
cmd = f"create {frameworks_cmd}{app_name} {self.buildozer.app_dir}"
else:
cmd = f"update {frameworks_cmd}{app_name}-ios"
self.toolchain(cmd)
# fix the plist
plist_fn = '{}-Info.plist'.format(app_name.lower())
plist_rfn = join(self.app_project_dir, plist_fn)
version = self.buildozer.get_version()
self.buildozer.info('Update Plist {}'.format(plist_fn))
plist = self.load_plist_from_file(plist_rfn)
plist['CFBundleIdentifier'] = self._get_package()
plist['CFBundleShortVersionString'] = version
plist['CFBundleVersion'] = '{}.{}'.format(version,
self.buildozer.build_id)
# add icons
self._create_icons()
# Generate OTA distribution manifest if `app_url`, `display_image_url` and `full_size_image_url` are defined.
app_url = self.buildozer.config.getdefault("app", "ios.manifest.app_url", None)
display_image_url = self.buildozer.config.getdefault("app", "ios.manifest.display_image_url", None)
full_size_image_url = self.buildozer.config.getdefault("app", "ios.manifest.full_size_image_url", None)
if any((app_url, display_image_url, full_size_image_url)):
if not all((app_url, display_image_url, full_size_image_url)):
self.buildozer.error("Options ios.manifest.app_url, ios.manifest.display_image_url"
" and ios.manifest.full_size_image_url should be defined all together")
return
plist['manifest'] = {
'appURL': app_url,
'displayImageURL': display_image_url,
'fullSizeImageURL': full_size_image_url,
}
# ok, write the modified plist.
self.dump_plist_to_file(plist, plist_rfn)
mode = self.build_mode.capitalize()
self.xcodebuild(
f'-configuration {mode}',
'-allowProvisioningUpdates',
'ENABLE_BITCODE=NO',
self.code_signing_allowed,
self.code_signing_development_team,
'clean build',
cwd=self.app_project_dir)
ios_app_dir = '{app_lower}-ios/build/{mode}-iphoneos/{app_lower}.app'.format(
app_lower=app_name.lower(), mode=mode)
self.buildozer.state['ios:latestappdir'] = ios_app_dir
intermediate_dir = join(self.ios_dir, '{}-{}.intermediates'.format(app_name, version))
xcarchive = join(intermediate_dir, '{}-{}.xcarchive'.format(
app_name, version))
ipa_name = '{}-{}.ipa'.format(app_name, version)
ipa_tmp = join(intermediate_dir, ipa_name)
ipa = join(self.buildozer.bin_dir, ipa_name)
build_dir = join(self.ios_dir, '{}-ios'.format(app_name.lower()))
self.buildozer.rmdir(intermediate_dir)
self.buildozer.info('Creating archive...')
self.xcodebuild(
'-alltargets',
f'-configuration {mode}',
f'-scheme {app_name.lower()}',
f'-archivePath "{xcarchive}"',
'-destination \'generic/platform=iOS\'',
'archive',
'ENABLE_BITCODE=NO',
self.code_signing_allowed,
self.code_signing_development_team,
cwd=build_dir)
key = 'ios.codesign.{}'.format(self.build_mode)
ioscodesign = self.buildozer.config.getdefault('app', key, '')
if not ioscodesign:
self.buildozer.error('Cannot create the IPA package without'
' signature. You must fill the "{}" token.'.format(key))
return
elif ioscodesign[0] not in ('"', "'"):
ioscodesign = '"{}"'.format(ioscodesign)
self.buildozer.info('Creating IPA...')
self.xcodebuild(
'-exportArchive',
f'-archivePath "{xcarchive}"',
f'-exportOptionsPlist "{plist_rfn}"',
f'-exportPath "{ipa_tmp}"',
f'CODE_SIGN_IDENTITY={ioscodesign}',
'ENABLE_BITCODE=NO',
cwd=build_dir)
self.buildozer.info('Moving IPA to bin...')
self.buildozer.file_rename(ipa_tmp, ipa)
self.buildozer.info('iOS packaging done!')
self.buildozer.info('IPA {0} available in the bin directory'.format(
basename(ipa)))
self.buildozer.state['ios:latestipa'] = ipa
self.buildozer.state['ios:latestmode'] = self.build_mode
def cmd_deploy(self, *args):
super().cmd_deploy(*args)
self._run_ios_deploy(lldb=False)
def cmd_run(self, *args):
super().cmd_run(*args)
self._run_ios_deploy(lldb=True)
def cmd_xcode(self, *args):
'''Open the xcode project.
'''
app_name = self.buildozer.namify(self.buildozer.config.get('app',
'package.name'))
app_name = app_name.lower()
ios_dir = ios_dir = join(self.buildozer.platform_dir, 'kivy-ios')
self.buildozer.cmd('open {}.xcodeproj'.format(
app_name), cwd=join(ios_dir, '{}-ios'.format(app_name)))
def _run_ios_deploy(self, lldb=False):
state = self.buildozer.state
if 'ios:latestappdir' not in state:
self.buildozer.error(
'App not built yet. Run "debug" or "release" first.')
return
ios_app_dir = state.get('ios:latestappdir')
if lldb:
debug_mode = '-d'
self.buildozer.info('Deploy and start the application')
else:
debug_mode = ''
self.buildozer.info('Deploy the application')
self.buildozer.cmd('{iosdeploy} {debug_mode} -b {app_dir}'.format(
iosdeploy=join(self.ios_deploy_dir, 'ios-deploy'),
debug_mode=debug_mode, app_dir=ios_app_dir),
cwd=self.ios_dir, show_output=True)
def _create_icons(self):
icon = self.buildozer.config.getdefault('app', 'icon.filename', '')
if not icon:
return
icon_fn = join(self.buildozer.app_dir, icon)
if not self.buildozer.file_exists(icon_fn):
self.buildozer.error('Icon {} does not exists'.format(icon_fn))
return
self.toolchain(f"icon {self.app_project_dir} {icon_fn}")
def check_configuration_tokens(self):
errors = []
config = self.buildozer.config
if not config.getboolean('app', 'ios.codesign.allowed'):
return
identity_debug = config.getdefault('app', 'ios.codesign.debug', '')
identity_release = config.getdefault('app', 'ios.codesign.release',
identity_debug)
available_identities = self._get_available_identities()
if not identity_debug:
errors.append('[app] "ios.codesign.debug" key missing, '
'you must give a certificate name to use.')
elif identity_debug not in available_identities:
errors.append('[app] identity {} not found. '
'Check with list_identities'.format(identity_debug))
if not identity_release:
errors.append('[app] "ios.codesign.release" key missing, '
'you must give a certificate name to use.')
elif identity_release not in available_identities:
errors.append('[app] identity "{}" not found. '
'Check with list_identities'.format(identity_release))
super().check_configuration_tokens(errors)
@no_config
def cmd_list_identities(self, *args):
'''List the available identities to use for signing.
'''
identities = self._get_available_identities()
print('Available identities:')
for x in identities:
print(' - {}'.format(x))
def _get_available_identities(self):
output = self.buildozer.cmd('security find-identity -v -p codesigning',
get_stdout=True)[0]
lines = output.splitlines()[:-1]
lines = [u'"{}"'.format(x.split('"')[1]) for x in lines]
return lines
def _unlock_keychain(self):
password_file = join(self.buildozer.buildozer_dir, '.ioscodesign')
password = None
if self.buildozer.file_exists(password_file):
with open(password_file) as fd:
password = fd.read()
if not password:
# no password available, try to unlock anyway...
error = self.buildozer.cmd('security unlock-keychain -u',
break_on_error=False)[2]
if not error:
return
else:
# password available, try to unlock
error = self.buildozer.cmd('security unlock-keychain -p {}'.format(
password), break_on_error=False, sensible=True)[2]
if not error:
return
# we need the password to unlock.
correct = False
attempt = 3
while attempt:
attempt -= 1
password = getpass('Password to unlock the default keychain:')
error = self.buildozer.cmd('security unlock-keychain -p "{}"'.format(
password), break_on_error=False, sensible=True)[2]
if not error:
correct = True
break
self.buildozer.error('Invalid keychain password')
if not correct:
self.buildozer.error('Unable to unlock the keychain, exiting.')
raise BuildozerCommandException()
# maybe user want to save it for further reuse?
print(
'The keychain password can be saved in the build directory\n'
'As soon as the build directory will be cleaned, '
'the password will be erased.')
save = None
while save is None:
q = input('Do you want to save the password (Y/n): ')
if q in ('', 'Y'):
save = True
elif q == 'n':
save = False
else:
print('Invalid answer!')
if save:
with open(password_file, 'wb') as fd:
fd.write(password.encode())
def get_target(buildozer):
return TargetIos(buildozer)