uniffi-rs icon indicating copy to clipboard operation
uniffi-rs copied to clipboard

Support passing platform-specific native object

Open paxbun opened this issue 1 year ago • 4 comments

I'm using UniFFI with the third-party Kotlin Multiplatform Binding generation on a Compose Multiplatform app. Since I'm accessing native APIs on the Rust side as well, I encounter cases where I want to pass native objects from the Kotlin side to the Rust side. For example, I may want to pass an Android Activity object to the Rust side using JNI. I also may want to pass UIViewController* on iOS.

However, since UniFFI passes all the values via serialization, there is no consistent way to pass such objects to the Rust side. I suggest adding a built-in pobject type that represents such types. For example, consider the following UDL file and the Rust code:

namespace foo {
  pobject multiply_by_two(pobject arg);
};
use uniffi::PlatformObject;

#[uniffi::export]
fn multiply_by_two(arg: PlatformObject) -> PlatformObject {
  /* arg.object() is *mut c_void here */
  if arg.is_objc() {
    let arg = arg.object() as *mut objc::Object;
    let NSInteger = objc::class!(NSINteger);
    ...
    PlatformObject::new_objc(result)
  } else if arg.is_java() {
    let arg = arg.object() as jni_sys::object;
    env.find_class("java/lang/Integer");
    ...
    PlatformObject::new_java(result)
  } else if arg.is_python() {
    let arg = arg.object() as *mut pyo3::ffi::PyObject;
    pyo3::ffi::PyLong_Type
    ...
    PlatformObject::new_python(result)
  } else {
    panic!("Unsupported platform {}!", arg.platform());
  }
}

The PlatformObject type is a pair of a value representing the platform type of object and a *mut c_void (or NonNull<c_void>) representing the object. PlatformObject is represented by the built-in pobject type in UDL. In the bindings, this will be exposed as id in Objective-C, kotlin.Any in Kotlin, and typing.Any in Python. I suggest the platform type value be a UUID encoded as a 128-bit integer (like COM interfaces).

When passing the object to the Rust side, the generated bindings will serialize the platform value and a pointer to the object, and when retrieving the object from the Rust side, the bindings will check the platform value and convert the pointer to a real object.

For third-party bindings, they may be able to provide an extension function checking whether the given PlatformObject is for that platform.

const UNIFFI_PLATFORM_NODE: /* some value type */ = /* some constant */;

trait PlatformObjectExt {
  fn is_node(&self) -> bool;
}

impl PlatformObjectExt for uniffi::PlatformObject {
  fn is_node(&self) -> bool {
    self.platform() == UNIFFI_PLATFORM_NODE
  }
}
use uniffi_node_bindings::PlatformObjectExt as _;

fn multiply_by_two(arg: PlatformObject) -> PlatformObject {
  if arg.is_node() {
    ...
  }
  ...
}

~~However, these features cannot be implemented for the moment on some bindings. For example, Kotlin bindings, as well as the JVM version of the third-party Kotlin multiplatform bindings, use JNA, and AFAIK there is no way to send Java objects as JNI's jobject using JNA. Also, for the third-party C#, Go, and Dart bindings, since there are no counterparts for something like JNI, this pobject is useless to them.~~

~~Nevertheless, I believe pobject will increase the utility of UniFFI, and bindings that does not support pobject can just ignore functions and records that use pobject.~~

(Edit 1: For the third-party bindings for C#, Go, and Dart, where there are no run-time native reflection features like JNI, they may choose to project pobject as IntPtr, uintptr, and Pointer<Void>. Consider the case where UniFFI is used with a WinForm C# project. The user may want to pass a HWND handle to the Rust side, which typically is represented as IntPtr in C# projects)

(Edit 2: On Swift projects, there may be cases where the user wants to pass both UnsafeRawPointer or Any to some function. In that case, the binding may generate an enum corresponding to pobject like the following and allow the user to choose one side)

enum PlatformObject {
  case objc(Any)
  case raw(UnsafeRawPointer)
}

(Edit 3: It seems JNA supports passing objects via JNI; it is just that JNI interoperability is unstable, i.e., there are many breaking changes even between minor versions. I think these features can be implemented even for now.)

I believe pobject will increase the utility of UniFFI, as I can think of many use cases for it.

paxbun avatar Jan 20 '24 09:01 paxbun

BTW, I'm using JNA and JNI in the same dynamic library; at first, I modified the template so System.loadLibrary is invoked right after Native.loadLibrary is called, but later, I kept the template as original and directly accessed to JNI_GetCreatedJavaVMs to get the current process's JavaVM.

paxbun avatar Jan 20 '24 09:01 paxbun

Hi @paxbun, I also would like to pass JNI objects from Kotlin to Rust. Do you have a fork of uniffi that implements this?

coolbluewater avatar Apr 17 '24 08:04 coolbluewater

@coolbluewater No, I didn't have any time to work on this... If you want features like this but have to pass few objects, you can just System.loadLibrary the same library and pass the JNI objects. I have an example for you: https://github.com/paxbun/GraphicsTest/blob/6ab7e5d8cdc2864653d91f6d7fa77924c27815d5/rust/src/lib.rs#L16-L39

This example passes android.view.Surface via JNI and UIView via a C function, and does the other things using UniFFI. Feel free to ask me about the example :)

Note that this example also uses the KMM UniFFI bindgen mentioned in my first comment, so you have to build the plugin manually, which is not merged to main yet. Clone the branch of the following PR: https://gitlab.com/trixnity/uniffi-kotlin-multiplatform-bindings/-/merge_requests/39 You can build it with ./gradlew :build-logic:gradle-plugin:publishToMavenLocal.

This plugin configures almost every environment variable you'd need, but I haven't tested it on Windows or Linux. Please let me know if you have any trouble building that plugin or the example!

I'm going to make a POC of this at least by July.

paxbun avatar Apr 17 '24 08:04 paxbun

I've been continuously investigating safer API design for this, but the existence of JNIEnv always brings me to something ugly or unsafe, which is thread-local but always required for practical use. This is the least ugly design I've come up with:

We need to put JNIEnv into a polymorphic object, which returns the underlying JNIEnv when called from a JVM and does nothing when called from other environments, such as Swift or Python. Since JNA does not support putting JNIEnv in a structure and modifying JNA to support it also does not make sense in the Java world, if we need to do this, the polymorphic object creation must be done on the Rust side, specifically in the scaffold.

For example, if a UniFFI function has a PlatformObject parameter, the scaffold for that function may look like this:

#[uniffi::export]
fn foo<'a>(env: uniffi::Env<'a>, platform_object: uniffi::PlatformObject) { ... }

pub unsafe extern "C" fn uniffi_my_package_fn_foo(
  env_identifier: u128,
  env: *const ::std::ffi::c_void,
  platform_object: *const ::std::ffi::c_void,
  call_status: &mut ::uniffi::RustCallStatus,
) {
  uniffi::rust_call(
    call_status,
    || {
      let env = uniffi::Env::new_unchecked(env_identifier, env);
      let platform_object = uniffi::PlatformObject::new_unchecked(platform_object);
      my_package::foo(env, platform_object);
    },
  );
}

and the Kotlin binding can call this function like uniffi_my_package_fn_foo(UNIFFI_IDENTIFIER_JAVA, JNIEnv.current, platformObject) and the others like uniffi_my_package_fn_foo(OTHER_IDENTIFIER, nullptr, platformObject).

Since env now has the identifier, functions like is_objc or is_java are all in uniffi::Env, not uniffi::PlatformObject.

#[uniffi::export]
fn foo<'a>(env: uniffi::Env<'a>, platform_object: uniffi::PlatformObject) {
  if env.is_java() {
    let platform_object = platform_object.into_inner() as jni_sys::object;
    ...
  }
}

But as you can see, this design forces the user to add an additional parameter when using a specific type of parameter, a new constraint that has never been in previous versions.

paxbun avatar May 01 '24 05:05 paxbun