Skip to main content

Enaml Native - iOS support - Cross compiling python and extensions for iOS

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

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:

  1. Libffi
  2. Openssl
  3. 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.


  1. Change the iPhoneOS productType from static to dynamic (com.apple.product-type.library.dynamic)
  2. 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.

  1. 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.
  1. Remove the "dynload.patch" 
  2. 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:

  1.  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`. 
  2. Copy all the renamed so files to your projects "Libs" folder
  3. Add them to the "Copy files" build phase and check "Sign on copy"
  4. 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


  1. https://stackoverflow.com/questions/42022884/making-xcode-embed-necessary-dylibs?rq=1
  2. https://www.mikeash.com/pyblog/friday-qa-2009-11-06-linking-and-install-names.html
  3. http://qin.laya.com/tech_coding_help/dylib_linking.html
  4. http://whatschrisdoing.com/blog/2009/10/16/cross-compiling-python-extensions/








Comments

Popular posts from this blog

Kivy vs React-Native for building cross platform mobile apps

I've built three apps now using Kivy and one with React-Native, just wanted to share my thoughts on both.

Just a warning, I am strongly biased towards python and this is all based on opinion and experience and is thus worth what you pay for it. I don't claim to be an expert in either of these, just have worked with each for several months.  If something is incorrect I'd love to hear advice.

Kivy
Demo of one of the apps



Pros:
Nice to be able to run natively on the desktop WITHOUT a simulatorPython is easy to work withUse (almost) any python libraryVery easy to create custom widgetsKivy properties and data binding just work. Way nicer than React's "state" / flux / redux whatever you want to call it (stupid?). Native interfaces (pyjnius) and (pyobjc)Runs and feels pretty smooth Cons:Default widget toolkit looks like Android 4.4. Requiring you use your own widgets or a theming kit like KivyMD if styling bothers youCreating dynamic widgets declaratively is not yet s…

Control Systems in Python - Part 1 - Bode and Step Response

I hate matlab with passion, yet sadly, nearly everyone uses it.  I'm a fan of Python and open source stuff so here's a simple article on how to do some common control systems stuff in Python.

First we need to make sure the environment is setup.
Install IPython (or you can use any other python shell, but a unicode supported shell is preferred)Install python-control (numpy, scipy)Install sympy
These should do if your on Ubuntu/debian:

sudo apt-get install python-sympy python-numpy python-scipy python-matplotlib ipython
Then you need to install python control, see How to download and install python-control
Intro to using SympyOpen ipython and run the following:

import sympy from sympy import * sympy.init_printing() s = Symbol('s')

Now we can do things like define transfer functions using the symbolic variable s.


We can expand the bottom using the .simplify() method

and we can do something more complex like...
which is really nice because it does all the multiplication for us... and it’…