macoskotlindylibcompose-multiplatform

How to call native functions in Compose Multiplatform application for MacOS?


I have a macOS application built using Compose Multiplatform. In this application, I need to call several native system functions, such as accessing a folder for writing or retrieving the device hostname.

As I understand it, this can be done by creating a dylib library using JNI and then calling the required methods from Kotlin. Here’s my current implementation:

MacOsUtils.swift:

@objc public class MacOsUtils: NSObject {
    @objc public func getDeviceHostName() -> String {
        if let hostName = Host.current().localizedName {
            print("Device host name: \(hostName)")
            return hostName
        } else {
            print("Unable to retrieve device host name")
            return "Unknown Device"
        }
    }
}

MacOsUtilsWrapper.mm:

#include <jni.h>
#include <string>
#include "MacOsUtils-Swift.h"
#include <Foundation/Foundation.h>

JavaVM* jvm = nullptr;

extern "C" {
JNIEXPORT jstring JNICALL Java_com_my_app_common_core_persits_MacOsUtils_getDeviceHostName(JNIEnv* env, jobject obj) {
    @autoreleasepool {
        MacOsUtils* macOsUtils = [MacOsUtils createInstance];
        if (!macOsUtils) {
            NSLog(@"Failed to create MacOsUtils instance");
            return nullptr;
        }

        NSString* deviceName = [macOsUtils getDeviceHostName];
        if (deviceName != nil) {
            const char* deviceNameCStr = [deviceName UTF8String];
            return env->NewStringUTF(deviceNameCStr);
        } else {
            return nullptr;
        }
    }
}
}

I compile the library with the following commands:

swiftc -emit-library -emit-objc-header -emit-objc-header-path MacOsUtils-Swift.h -static -o libMacOsUtils.a MacOsUtils.swift
clang++ -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/darwin" -I"." -shared -o libMacOsUtilsWrapper.dylib MacOsUtilsWrapper.mm -L. -lMacOsUtils -framework Foundation -Wl,-all_load

I sign the library with this command:

codesign --force --sign "3rd Party Mac Developer Application: My Name (TEAM ID)" --timestamp --options runtime libMacOsUtilsWrapper.dylib

The libMacOsUtilsWrapper.dylib file is then placed in the common/src/desktopMain/resources folder. In my Kotlin code, I copy the library to a temporary directory and attempt to load it as follows:

object MacOsUtils {

    init {
        loadLibrary()
    }

    private fun loadLibrary() {
        System.setProperty("jna.nosys", "true")
        val libName = "libMacOsUtilsWrapper.dylib"
        checkNotNull(this::class.java.classLoader?.getResource(libName)) {
            "Library $libName not found in resources"
        }

        val tmpDir = Files.createTempDirectory("MyApp").toFile()
        Napier.d("Loading $libName to $tmpDir")
        val tmpLib = File(tmpDir, libName)

        if (!tmpLib.exists()) {
            this::class.java.classLoader?.getResourceAsStream(libName)?.use { inputStream ->
                FileOutputStream(tmpLib).use { outputStream ->
                    inputStream.copyTo(outputStream)
                }
            }
        }
        @Suppress("UnsafeDynamicallyLoadedCode")
        System.load(tmpLib.absolutePath)
    }

    external fun getDeviceHostName(): String?
}

Locally, when running the application from Android Studio, everything works correctly. However, it does not work after publishing to TestFlight. When launching the app, I encounter this error:

The file "libMacOsUtilsWrapper.dylib" couldn’t be opened because Apple cannot check it for malicious software.

I have three questions:

  1. Is this the correct way to call native functions in a Compose Multiplatform macOS application?
  2. How should the dylib file be signed correctly? Currently, I use the 3rd Party Mac Developer Application identity. Is this the right approach?
  3. How should the dylib file be packaged with the application so that it works both when running the app from Android Studio and for end users after publishing to TestFlight/App Store?

Any advice or guidance would be greatly appreciated. Thank you!


Solution

  • I managed to integrate a native .dylib library into my Kotlin Multiplatform Desktop (Compose for Desktop) application successfully. Here’s the step-by-step guide, starting with the implementation, moving to compilation and placement, and finishing with how to use it in your project.


    Solution Steps:

    1. Write the Native Code

    The integration consists of three parts: Swift, Objective-C (as a bridge), and Kotlin.


    2. Compile and Sign the .dylib Library

    Use the following commands to compile and sign the library:

    # Build the static library and Objective-C header
    swiftc -emit-library -emit-objc-header -emit-objc-header-path MacOsUtils-Swift.h -static -o libMacOsUtils.a MacOsUtils.swift
    
    # Create the `.dylib` library
    clang++ -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/darwin" -I"." -shared -o libMacOsUtilsWrapper.dylib MacOsUtilsWrapper.mm -L. -lMacOsUtils -framework Foundation -Wl,-all_load
    
    # Sign the `.dylib` for App Store/TestFlight
    codesign --force --sign "3rd Party Mac Developer Application: Your Name (TEAMID)" --timestamp --options runtime libMacOsUtilsWrapper.dylib
    

    3. Place the .dylib File in the Project

    After compiling and signing the library:

    1. Create the directory desktop/resources/macos-arm64.
    2. Place the libMacOsUtilsWrapper.dylib file in this directory.

    When the application is installed from App Store or TestFlight, the library will be placed in /Applications/MyApp.app/Contents/app/resources.


    4. Configure the Project

    Update your desktop/build.gradle.kts to include the following:

    compose.desktop {
        application {
            nativeDistributions {
                appResourcesRootDir.set(project.layout.projectDirectory.dir("resources"))
            }
        }
    }
    

    This ensures that the resources directory is packaged with your application.


    5. Use the Native Function in Kotlin

    Now you can use the native function anywhere in your Kotlin application as follows:

    val hostName = MacOsUtils.getDeviceHostName()
    println("Device Host Name: $hostName")
    

    The library will be loaded dynamically at runtime, and the native functionality will be accessible via the MacOsUtils object.