I'm working on adding iOS support to enaml-native and the major step in doing this is cross compiling Python AND python modules for iOS.
Well after a few long nights over the past two weeks, I got it! Since I've spent literally days figuring this out. I thought I'd share my findings and how I fixed a lot of the roadblocks I ran into.
Please note this is for targeting devices with iOS 8 and up. Older versions do not allow linking dynamic libraries on the App Store (at least thats my understanding).
Well since iOS 8 (we're at 10.2 now?) this has changed and dynamic libs are actually now recommended to be used.
The only projects I found for building Python as a shared library was this, which builds python 2.7.6 and says "Only for jailbroken devices, better than sandboxed version in App Store" and was last updated 4 years ago, and requires prebuilt libraries.
There's also this which builds 2.7.13 but uses libffi from clydia and comes with precompiled ssl libraries, no thanks. You should NEVER trust precompiled ssl libraries (unless they're signed by a reputable source)!
So I learned from all these projects and built one.
I had to update the following recipes to start:
Now we have dylibs for everything needed by python, so add it to the xcode project.
I had to modify kivy-ios to NOT do all the stuff required for static libraries (biglink and lipo). Likewise I made it copy the dylibs instead of the static libaries to the dist folder.
So at this point we had python and it's dependencies all built as dylibs for each arch (x86_64, i386, armv7, and arm64).
All that work and we get a library not loaded error. The libraries are right there?
So run `otool -L libcrypto.dylib` and we see it's install name is pointing to the location above. We have to tell it to load from somewhere else.
So after a lot of digging, I found Linking and Install Names which describes using @rpath, and Creating working dylibs which are two really good reads on dylibs and the linking process.
So now, we have to go through every configure script and find the flags you must set to change the install_name to use @rpath so we can configure it during the build process.
Turns out most of them have it, but half of them didn't work. Many of them I had to add patches to the makefile after configure was run.
Since libffi uses an xcode project we have to do the equivalent of setting the "Install Directory" see the nice stack overflow answer here. So I made a patch that sets LD_DYLIB_INSTALL_NAME = "@rpath/libffi.dylib"; for each product (Debug and Release).
Rebuild, run `otool -L libffi.dylib` and the path is now `@rpath/libffy.dylib` nice!
It will still build so files but they work fine. You can make sure they're built for the correct arch using the "file" command.
Well after a few long nights over the past two weeks, I got it! Since I've spent literally days figuring this out. I thought I'd share my findings and how I fixed a lot of the roadblocks I ran into.
Please note this is for targeting devices with iOS 8 and up. Older versions do not allow linking dynamic libraries on the App Store (at least thats my understanding).
Cross compiling Python for iOS
Most of the existing "Python for iOS" and similar toolchains (ex kivy-ios and python-for-ios) compile everything as static '.a' libraries since old versions of iOS didn't allow dynamic libraries.Well since iOS 8 (we're at 10.2 now?) this has changed and dynamic libs are actually now recommended to be used.
The only projects I found for building Python as a shared library was this, which builds python 2.7.6 and says "Only for jailbroken devices, better than sandboxed version in App Store" and was last updated 4 years ago, and requires prebuilt libraries.
There's also this which builds 2.7.13 but uses libffi from clydia and comes with precompiled ssl libraries, no thanks. You should NEVER trust precompiled ssl libraries (unless they're signed by a reputable source)!
So I learned from all these projects and built one.
Where to start?
I started with a fork of kivy-ios. Specifically the fork from this pull request (which built fine out of the box for me).I had to update the following recipes to start:
- Libffi
- Openssl
- Python
Converting to shared libraries
All the kivy-ios recipes were updated to build dynamic libraries. You have to read through the makefiles and configure scripts to figure out which arguments to pass to configure. It is a very SLOW process.Libffi
Libffi comes with an xcode project for building for iOS. By default it creates a static library. I had to modify the xcode project as follows.- Change the iPhoneOS productType from static to dynamic (com.apple.product-type.library.dynamic)
- And rename libffi.a to libffi.dylib.
Rebuild and now we have a libff.dylib for the iPhone.
Openssl
Openssl was pretty simple to convert.- Add the "-shared" flag to the configure call
Rebuild and we have libcrypto.dylib and libssl.dylib (version 1.0.2l).
Python
The python recipe was simple to convert to a dylib.
- Remove the "dynload.patch"
- Add the "--enable-shared" flag to the configure arguments
Now we have dylibs for everything needed by python, so add it to the xcode project.
I had to modify kivy-ios to NOT do all the stuff required for static libraries (biglink and lipo). Likewise I made it copy the dylibs instead of the static libaries to the dist folder.
So at this point we had python and it's dependencies all built as dylibs for each arch (x86_64, i386, armv7, and arm64).
Fixing install names
To make sure python worked (on the simulator for now), I copied the x86_64 libraries to the xcode project, added them as linked libraries, added a build phase to copy the files (check sign on copy), ran and:
dyld: Library not loaded: /usr/local/ssl/lib/libcrypto.1.0.0.dylib
Referenced from: /Users/jrm/Library/Developer/CoreSimulator/Devices/76B...0E82E/data/Containers/Bundle/Application/567...DF8A/app.app/app
Reason: image not found
(lldb)
All that work and we get a library not loaded error. The libraries are right there?
So run `otool -L libcrypto.dylib` and we see it's install name is pointing to the location above. We have to tell it to load from somewhere else.
So after a lot of digging, I found Linking and Install Names which describes using @rpath, and Creating working dylibs which are two really good reads on dylibs and the linking process.
So now, we have to go through every configure script and find the flags you must set to change the install_name to use @rpath so we can configure it during the build process.
Turns out most of them have it, but half of them didn't work. Many of them I had to add patches to the makefile after configure was run.
Libffi
Since libffi uses an xcode project we have to do the equivalent of setting the "Install Directory" see the nice stack overflow answer here. So I made a patch that sets LD_DYLIB_INSTALL_NAME = "@rpath/libffi.dylib"; for each product (Debug and Release).
Rebuild, run `otool -L libffi.dylib` and the path is now `@rpath/libffy.dylib` nice!
mbp:armv7 jrm$ otool -L libffi.dylib
libffi.dylib:
@rpath/libffi.dylib (compatibility version 1.0.0, current version 1.0.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1238.0.0)
Openssl
Updating the install path for openssl was a major pain. The configure script has a nice description on how you should be able to do this by passing "--install_prefix" during the configure stage. Well that's nice, but it doesn't work! It actually uses the "--prefix" (or "--openssldir") as the install prefix and completely ignores the flag. It also appends the "LIBDIR" to the prefix
So add "--prefix=@rpath" and pass "LIBDIR=ssl" during make, rebuild and run `otool -L libssl.dylib`.
mbp:armv7 jrm$ otool -L libssl.dylib
libssl.dylib:
@rpath/ssl/libssl.1.0.2.dylib (compatibility version 1.0.2, current version 1.0.2)
@rpath/ssl/libcrypto.1.0.2.dylib (compatibility version 1.0.2, current version 1.0.2)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1238.0.0)
Nice!
Python
The python makefile actually hardcodes the install to prefix/lib, and it doesn't let you pass something that is not a path for prefix (ie @rpath). It took a while to figure that out. But the fix is easy. To change it you have to patch the Makefile (with sed or equivalent) which updates the liblink call.
#: Patch the makefile to use the install_name "@rpath/"shprint(sh.sed, '-ie',
"s!-install_name,$(prefix)/lib/libpython$(VERSION).dylib!"
"-install_name,@rpath/libpython$(VERSION).dylib!",
"Makefile")
Rebuild, run otool and:
mbp:armv7 jrm$ otool -L libpython.dylib
libpython.dylib:
@rpath/libpython2.7.dylib (compatibility version 2.7.0, current version 2.7.0)
/usr/lib/libsqlite3.dylib (compatibility version 9.0.0, current version 253.0.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1238.0.0)
/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation (compatibility version 150.0.0, current version 1348.22.0)
Test it out
Now we have libffi, openssl, and python all compiled for each arch, and install_names updated to use rpath. Now add the new libraries to your xcode project and add "@rpath/Libs" (where you have the copy files put them) to the "Runpath search paths".
Clean and build, and boom!
2.7.13 (default, Jul 25 2017, 13:02:10)
So thats nice on a simulator, but we need it to work on a real phone. I have an iPhone 7, so, now copy in the dylibs for your arch and run it on the device.
Python extensions
So what about my extension? Well since we're using shared libraries, they build as normal, we just have to trick the compiler into building for the correct arch.
You have to use the magic flag "_PYTHON_HOST_PLATFORM" buried deep within distutils. Then just run "hostpython setup.py build_ext" and your "normal" cross compile flags (see http://whatschrisdoing.com/blog/2009/10/16/cross-compiling-python-extensions/).
This is the recipe used:
def get_local_arch(self, arch): if arch.arch == "arm64": return "aarch64" elif arch.arch=="armv7": return "arm" else: return arch.arch def get_recipe_env(self, arch): env = super(PythonRecipe, self).get_recipe_env(arch) env["KIVYIOSROOT"] = self.ctx.root_dir env["IOSSDKROOT"] = arch.sysroot env["ARM_LD"] = env["LD"] env["ARCH"] = arch.arch env["C_INCLUDE_PATH"] = join(arch.sysroot, "usr", "include") env["LIBRARY_PATH"] = join(arch.sysroot, "usr", "lib") env["CFLAGS"] += " -I{}".format( join(self.ctx.dist_dir, "include", arch.arch, "libffi"), #join(self.ctx.dist_dir, "lib", arch.arch, "libbz2"), ) env['LDFLAGS'] += " -shared -lpython -lffi -lz -L{}".format( join(self.ctx.dist_dir, "lib", arch.arch), ) env['PYTHONXCPREFIX'] = self.get_build_dir(arch.arch) env['LDSHARED'] = "{CC} -shared".format(**env) env['CROSS_COMPILE'] = arch.arch env['CROSS_COMPILE_TARGET'] = 'yes' env['_PYTHON_HOST_PLATFORM'] = 'darwin-{}'.format(self.get_local_arch(arch)) if "openssl.build_all" in self.ctx.state: env['LDFLAGS'] += " -lssl -lcrypto" env['CFLAGS'] += " -I{}".format( join(self.ctx.dist_dir, "include", arch.arch, "openssl"), ) return env
It will still build so files but they work fine. You can make sure they're built for the correct arch using the "file" command.
mbp:site-packages jrm$ file catom.so
catom.so: Mach-O dynamically linked shared library arm_v7
Signing and loading complied extensions
iOS requires all shared libraries to be signed or it will NOT load them (which is really good!). To get xcode to sign them for us and load them. The following works:
- Rename all `.so` files `package.name.so` for example I'm using atom, so i rename `catom.so` from the `atom` package to `atom.catom.so`.
- Copy all the renamed so files to your projects "Libs" folder
- Add them to the "Copy files" build phase and check "Sign on copy"
- Use the python import_hook below to load them
'''Copyright (c) 2017, Jairus Martin.Distributed under the terms of the MIT License.The full license is in the file COPYING.txt, distributed with this software.Created on July 10, 2017@author: jrm'''import sys import imp from glob import glob from os.path import dirname, join class SoLoader(object): """ Loads renamed so files from the app's lib folder""" so_modules = {} def load_module(self, mod): try: return sys.modules[mod] except KeyError: pass lib = SoLoader.so_modules[mod] m = imp.load_dynamic(mod, lib) #m.__file__ = mod #m.__path__ = [] #m.__loader__ = self sys.modules[mod] = m return m def __init__(self, path=None): #: Find all included so files lib_dir = join(dirname(sys.path[0]),'Lib')#path or os.environ['APK_LIB_DIR'] print("Loading libs from %s"%lib_dir) print("Contents: {}".format(glob('%s/*'%lib_dir))) for lib in glob('%s/*.so'%lib_dir): name = lib.split("/")[-1] # Lib filename mod = ".".join(name.split(".")[:-1]) # Strip so SoLoader.so_modules[mod] = lib #print(AndroidFinder.so_modules) def find_module(self, mod, path = None): if mod in self.so_modules: return self return None
And add the loader to your main.py script
from import_hooks import SoLoader sys.meta_path.append(SoLoader())
Now it will load any renamed, signed, python extensions you add to your Libs directory!
Final remarks
Well that was a royal pain. Now all I have to do is see how to pack all the libs for the different arches into a framework and hopefully it doesn't get rejected by the App Store!
I didn't spend too much time on this article as it's more of a brain dump for me so when I come back later and wonder what in the world I was doing I at least have some idea.
The actual code for all this is in the enaml-native project.
Cheers!
References
- https://stackoverflow.com/questions/42022884/making-xcode-embed-necessary-dylibs?rq=1
- https://www.mikeash.com/pyblog/friday-qa-2009-11-06-linking-and-install-names.html
- http://qin.laya.com/tech_coding_help/dylib_linking.html
- http://whatschrisdoing.com/blog/2009/10/16/cross-compiling-python-extensions/
Comments
Post a Comment