ditto_live 4.12.1

SDKflutter
Platformandroidioslinuxmacosweb

The Ditto Flutter SDK is an edge sync platform allowing devices to synchronize data with or without an internet connection. For more info, go to https://docs.ditto.live

What is the "cross-platform" API

Flutter has a file core.dart which exposes an API which is usable on Wasm and native platforms. Each platform’s implementation depends on core libraries which aren’t available on the other (dart:ffi is native only, while dart:js_interop is web only).

As such, we need to expose something which is free from any types from any platform-exclusive core library.

This document is an attempt to collect the “rules” for what the cross-platform API should contain, and what it should not contain.

General

In general, we try to mimic the API generated by ffigen found in generated_bindings.dart as closely as possible. This isn’t always possible/ideal, so exceptions are listed below.

Pointers

Pointer<T> is from dart:ffi, so we need our own pointer type. It is called CPPointer<T> where T is a subtype of CPPointerTarget. In Rust terms, CPPointerTarget is effectively a “marker trait”, and all the subtypes of it (CPDitto, CPIdentityConfig, etc.) are ZSTs that are only used as type parameters to CPPointer<T>

CPPointer is an abstract class with separate WASM and native implementations, both of which are totally opaque. We never do pointer arithmetic or any other operation on the data of a pointer. We only ever receive them from core, and pass them to core.

Avoid "out pointers"

Some APIs use "out pointers", which are pointers that are passed as arguments whose only purpose is for the function to write data to. For example:

fn do_something(bytes_written: &mut u64) {
    // do stuff...

    *bytes_written = 123;
}

In Dart, we would consume this API like this:

final bytesWritten = malloc<Uint64>();
bindings.do_something(bytes_written);

expect(bytesWritten.ref, equals(123));

Unfortunately, malloc is from package:ffi (which depends on dart:ffi), so cannot be used by consumers of the cross-platform API.

We could expose malloc as part of the cross-platform API:

CPPointer<T> malloc<T extends CPPointerTarget>([int count = 1]) => ...;

But this is risky, because we don't preserve type safety between cross-platform pointers and regular pointers. Internally, a regular pointer just stores a Pointer<Void>, because there's no way to model the bidirectional mapping between cross-platform and regular types. Essentially we'd need a trait with an associated type, which Dart doesn't support.

So instead of exposing this API as-is, we treat out pointers as return values. Dart supports records (tuples in Rust terms), which can simulate multiple returns. So the following Rust function:

// a, b are regular parameters
// out_1 and out_2 are out pointers
// it returns a bool
fn do_thing(a: i32, b: i32, out_1: &mut i32, out_2: &mut i32) -> bool {}

would give a Dart cross-platform function with the signature (bool, int, int) Function(int, int), which could be implemented in native like so:

(bool, int, int) doThing(int a, int b) {
    final out1 = malloc<Int32>();
    final out2 = malloc<Int32>();

    final result = bindings.do_thing(a, b, out1, out2);
    return (result, out1.ref, out2.ref);
}

A note on finalization

Finalization (a.k.a. destructors) behaves a little different in Dart compared to Rust. Instead of being a property of a type, it is a property of a value. A Finalizer can be attached to a specific object (not class). The type itself must also implement Finalizable, but simply implementing Finalizable does very little without attaching a Finalizer.

Correctly attaching finalizers is the responsibility of each platform’s implementation. This is because native code actually uses NativeFinalizer instead of Finalizer, which is from dart:ffi.

In other words, code outside of core can assume that finalizers are attached to instances of CPPointer as required, such that dereferencing a CPPointer is always safe. If we need some mechanism to have early finalization, I’m not opposed to adding a flag to CPPointer and throwing/returning null if it’s invalid (similar to how WeakReference works)

String / Uint8List

Strings and bytes get special treatment. While they could feasibly be implemented as CPPointer<CPChar> or CPPointer<CPUint8>, two factors mean that it makes more sense to have the plain Dart types in the interface:

  • Wasm has special handling, which means that a cross-platform implementation of a CPPointer<CPChar> is harder than it seems,
  • They are handled slightly differently by different FFI functions, so we still have to do mapping in most cases (is a String a char* or a slice_uint8_ref?).

Functions

Similarly to Strings, regular Dart functions are OK as part of the cross-platform API. It’s up to each implementation to correctly convert them to something their core implementation can handle (NativeCallable for native, JSFunction for WASM).

Function types must be fully declared, i.e.:

// bad
void takesCallback(Function callback) { /* stuff */ }

// good
void takesCallback(void Function(int) callback) { /* stuff */ }

// better
void takesCallback(void Function(int seconds) callback) { /* stuff */ }

We’re not super consistent about this in the SDK, since the linter rules aren’t great, but it’s something we should focus on in new code.

Async

The cross-platform abstraction should only expose async-ness where it is required by one of the platforms. In other words, if the C header has a continuation-style API, we should implement that in the shared code that consumes the cross-platform API:

// bad
Future<void> resolvesAsync() { /* ... */ }

// good
void resolvesAsync(void Function() onComplete) { /* .. */ }

The reasoning behind this is that, if both WASM and native provide a completion-based API, if we implement the Future-returning function twice, we’re duplicating work.

In the case where Wasm doesn’t provide a synchronous function, then we can implement the other side in Dart to provide a shared Future-returning API, but that’s only if one side only offers a Future-returning API.