flet-dev / flet

Flet enables developers to easily build realtime web, mobile and desktop apps in Python. No frontend experience required.
https://flet.dev
Apache License 2.0
11.5k stars 449 forks source link

Linking to or packaging native libraries with a Flet app #4091

Open kentbull opened 1 month ago

kentbull commented 1 month ago

Duplicate Check

Describe the requested feature

What is the recommended approach to linking a needed system library into my Flet app?

I depend on libsodium (through pysodium), a popular cryptography library, at /usr/local/lib/libsodium.23.dylib yet get the runtime code signing error

OSError: dlopen(/usr/local/lib/libsodium.dylib, 0x0006): tried: '/usr/local/lib/libsodium.dylib' (code signature in <0541E863-5412-3036-A49C-84E95E4A0C98> '/usr/local/lib/libsodium.23.dylib' not valid for use in process: mapping process and mapped file (non-platform) have different Team IDs)

This is after doing the codesigning and notarization to build my .dmg file for distribution.

I see the Flutter discussion of Flutter tools support for packaging/linking to native libraries and am wondering if I need to take some FFI approach or just include the libsodium.23.dylib as a part of my build and codesigning process, as in

This seems a bit heavyweight, though if that's what I must do, then that's what I will do.

A relevant issue from the Flutter repo is https://github.com/flutter/flutter/issues/33227 (Flutter tools support for packaging/linking to native libraries)

This flet issue is probably related: https://github.com/flet-dev/flet/issues/2823 (libmpv.so.1 not found (when libmpv is already installed) - fixable but hacky)

Suggest a solution

I suggest creating a strong recommendation in the Flet documentation to do static linking: downloading and building dependent libraries for each target architecture and linking them to the built Flutter app during the build process.

Screenshots

No response

Additional details

No response

kentbull commented 1 month ago

I was able to get a workaround functional for pysodium by modifying the DYLD_LIBRARY_PATH and LD_LIBRARY_PATH variables as well as symlinking to the correct binary in a libsodium library directory in my repository.

This works because the entire app repo is copied to the app cache directory of ~/Library/Caches/myapp/app/ when running the app from the .dmg I build after making flet build macos.

Here's sample code of what I had to do to get pysodium to load my libsodium dynamic library from the libsodium/ directory in my repository.

################################# Custom Libsodium Loader ############################################
# This code has to be in the main module to avoid a partially initialized module error
# for the main app module.

def load_custom_libsodium(appdir):
    """
    Instruct the pysodium library to load a custom libsodium dylib from the appdir/libsodium
    """
    set_load_path_or_link(appdir)
    set_load_env_vars(appdir)

    custom_path = os.path.expanduser(f'{os.path.dirname(os.path.abspath(__file__))}/libsodium/libsodium.dylib')
    logger.info(f'Loading custom libsodium from {custom_path}')
    if os.path.exists(custom_path):
        logger.info(f'Found custom libsodium at {custom_path}')
        sodium = ctypes.cdll.LoadLibrary(custom_path)
    else:
        logger.info('Custom libsodium not found, loading from system')
        libsodium_path = find_library('sodium')
        if libsodium_path is not None:
            logger.info(f'Found libsodium at {libsodium_path}')
            sodium = ctypes.cdll.LoadLibrary(libsodium_path)
            logger.info(f'Loaded libsodium from {libsodium_path}')
        else:
            raise OSError('libsodium not found')

def set_load_path_or_link(appdir):
    """
    Symlinks the correct libsodium dylib based on the architecture of the system.
    """
    lib_home = f'{appdir}/libsodium'
    arch = platform.processor()

    if platform.system() == 'Windows':
        match arch:
            case 'x86' | 'i386' | 'i486' | 'i586' | 'i686':
                sodium_lib = 'libsodium.26.x32.dll'
            case 'AMD64' | 'x86_64':
                sodium_lib = 'libsodium.26.x64.dll'
            case _:
                raise OSError(f'Unsupported Windows architecture: {arch}')
    elif platform.system() == 'Darwin':
        match platform.processor():
            case 'x86_64':
                sodium_lib = 'libsodium.26.x86_64.dylib'
            case 'arm' | 'arm64' | 'aarch64':
                sodium_lib = 'libsodium.23.arm.dylib'
            # doesn't work
            case 'i386':
                sodium_lib = 'libsodium.23.i386.dylib'
            case _:
                raise OSError(f'Unsupported architecture: {platform.processor()}')
    else:
        # Linux and other Unix-like systems
        sodium_lib = 'libsodium.so.23'  # not yet supported
        raise OSError(f'Unsupported architecture: {platform.processor()}')

    lib_path = Path(os.path.join(lib_home, sodium_lib))

    logger.info(f'Arch: {platform.processor()} Linking libsodium lib: {sodium_lib} at path: {lib_path}')

    if platform.system() == 'Windows':  # if windows just set the PATH
        logger.info(f'Setting PATH to include {lib_path}')
        os.environ['PATH'] = f'{lib_path};{os.environ["PATH"]}'
    elif platform.system() == 'Darwin':  # if macOS, symlink the dylib
        if not lib_path.exists():
            logger.error(f'libsodium for architecture {platform.processor()} missing at {lib_path}, cannot link')
            raise FileNotFoundError(f'libsodium for architecture {platform.processor()} missing at {lib_path}')

        link_path = Path(os.path.join(lib_home, 'libsodium.dylib'))
        logger.info(f'Symlinking {lib_path} to {link_path}')
        try:
            os.symlink(f'{lib_path}', f'{link_path}')
        except FileExistsError:
            os.remove(f'{link_path}')
            os.symlink(f'{lib_path}', f'{link_path}')
        logger.info(f'Linked libsodium dylib: {link_path}')

def set_load_env_vars(appdir):
    """
    Sets the DYLD_LIBRARY_PATH and LD_LIBRARY_PATH that pysodium uses to find libsodium to the custom libsodium dylib.
    """
    if platform.system() == 'Windows':
        return  # Windows doesn't need this

    local_path = appdir

    logger.info(f'Setting DYLD_LIBRARY_PATH to {local_path}/libsodium')
    os.environ['DYLD_LIBRARY_PATH'] = f'{local_path}/libsodium'

    logger.info(f'Setting LD_LIBRARY_PATH to {local_path}/libsodium')
    os.environ['LD_LIBRARY_PATH'] = f'{local_path}/libsodium'

################################### End Custom Libsodium Loader ######################################

This is just for MacOS machines. I haven't tested the Windows support yet.

Static linking would completely remove the need to do this dynamic module loading.