''' 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)