Platforms

From docs/platforms.md at 72c38f3 View on GitHub

Six platforms, six drivers, one model. Every driver reads a native API and produces the same intermediate representation, which is what lets one facade sit over all of them. What differs is what each one can see, and what it has to give up — recorded as a marker rather than dropped.

Linux, Windows and desktop C — the c driver

Reads C headers with ffigen, through its library API and its visitor interface, and writes a dart:ffi binding.

linux:
  driver: c
  headers: [third_party/example/include/example.h]
  include:
    functions: ["example_*"]
    structs: ["example_*"]
    enums: ["Example*"]
  deps:
    pkg_config: [glib-2.0]
  build:
    sources: [src/example.c]

Nothing is included by default: entry-point headers are selected by the headers: list and symbols by include:, while typedefs follow whatever uses them. On Linux, deps.pkg_config runs pkg-config --cflags for each entry and passes the resulting -I and -D flags to libclang through the driver's compilerOptions. What the driver cannot promise is marked rather than hidden — a variadic function binds its fixed arguments only, a union shares storage, a C enum has an implementation-defined width, and a function-pointer typedef needs Pointer.fromFunction or NativeCallable on the Dart side, and GLib's G_OBJECT() cast macros and g_object_new varargs carry verify markers because ffigen cannot bind them.

The facade over C is where most of the marshalling lives. A char* crosses through an arena, an opaque handle becomes a wrapper with a dispose, an enum crosses by constant name, and a struct is built with ffi.Struct.create — on the Dart heap, so it is garbage-collected and has no dispose at all. A struct passed by pointer is an arena copy read back field by field after the call, because dart:ffi copies a struct into a pointer and has nothing that copies one out; a callee that keeps that pointer outlives the copy, which is what the marker on those calls says.

Apple — the objc driver

The same generator as C, in Objective-C mode: ffigen supplies -x objective-c and the SDK sysroot itself, which is why generating for Apple platforms needs macOS. It writes two files, a Dart binding and an Objective-C glue .m that has to be compiled into your framework.

macos:
  driver: objc
  headers: [third_party/Example.framework/Headers/Example.h]
  include:
    types: ["EX*"]
    protocols: ["EX*Delegate"]
ios:
  inherit: macos

Everything about Objective-C that can go wrong goes wrong at the call rather than at compile time, so read the markers here. A protocol is implemented from Dart through the static implement on its generated $Builder. A block that the callee invokes from another thread needs the listener constructor, not fromFunction. An NSError** argument is not in the Dart signature at all — ffigen throws instead, and the marker says so. An @optional protocol method throws when you did not implement it.

Apple, Swift-first libraries — the swift driver

Dart reaches Swift through Objective-C, so this is three translations deep: swift2objc writes an @objc wrapper class, swiftc emits the Objective-C header for it, and ffigen binds that header. The driver returns all three artifacts, and all three ship.

Two limits of swift2objc are load-bearing and will not be worked around: a generic Swift type produces a wrapper that does not compile, so keep it out of the pull list; and a method taking an array parameter is dropped with nothing but a log record, which the driver turns into a marker. Everything the wrapper path can recover — structs, generics, async — is generated as an @objc bridge you commit and feed back through the same driver.

A Swift async method reaches Dart as a completion handler that Swift calls from its own executor thread. That is a listener block, and an ordinary one deadlocks.

Android — the jvm driver

jnigen reads Java and Kotlin, from sources or from a classpath, and writes bindings over package:jni.

android:
  driver: jvm
  compile_sdk: 35
  deps:
    maven: ["androidx.biometric:biometric:1.2.0-alpha05"]
    repositories:
      - https://maven.google.com
      - https://repo1.maven.org/maven2
  include:
    classes: ["androidx.biometric.BiometricPrompt*"]
  kotlin:
    suspend: future
    flow: stream

Maven resolution is bindsmith's own rather than jnigen's, because jnigen resolves by writing a stub Gradle project and running gradlew — a tool driven through its console output, pinning nothing. One repository is not enough in practice: an AndroidX artifact usually comes from Google's Maven while the Kotlin standard library it needs comes only from Central, so repositories: is a list searched in order. Version ranges, LATEST, RELEASE and -SNAPSHOT are refused by name: each makes one coordinate mean different bytes on a different day, and there is nothing honest to write in a lockfile. bindsmith resolve writes that lockfile; see pinning what you depend on. A repository is https:// or a file:// directory — the same two Gradle takes — so a vendored or mirrored repository on disk works without a network at all.

From Kotlin, a suspend fun binds as a Dart Future. A function returning Flow binds too, but carries a marker, because Dart gets the raw Flow object and nothing that collects it. A default argument is invisible to Dart: the binding takes every parameter, and the f$default method Kotlin compiles for the shorter calls is listed as dropped in bindsmith dump. Annotate the function or constructor with @JvmOverloads to bind each arity. jnigen needs package:jni to resolve from the project — a Flutter plugin gets it from flutter pub get — and bindsmith checks that before running it, since jnigen would otherwise end the process.

A release build also has to be told to keep the classes the binding uses: jnigen finds each one by name at run time, which R8 cannot see, so without rules the app fails with ClassNotFoundException — in release only. bindsmith writes two files for that beside your package's android/build.gradle, never into it: android/bindsmith-rules.pro, one -keep rule per bound class, and android/bindsmith.gradle, which hands those rules to every app using the package (consumerProguardFiles) and declares the Maven dependencies. Apply the script once with apply from: 'bindsmith.gradle', and set ffiPlugin: true under flutter.plugin.platforms.android in the pubspec so that Flutter builds the library at all. bindsmith generate writes the binding, the facade, and both Android glue files from the lock and the configured compile_sdk.

Kotlin constructs that have no Java shape — suspend, Flow, default parameters, sealed classes — are bridged by generated Kotlin you commit, the same way the Swift wrapper works.

Windows — the winmd driver

There is no upstream generator for Windows. package:winmd reads Windows metadata and does not emit anything, so bindsmith writes the dart:ffi binding itself, and the work splits into a driver that turns metadata into the model and an emitter that turns the model into Dart. Both run on any host.

windows:
  driver: winmd
  deps:
    nuget: ["Microsoft.Windows.SDK.Win32Metadata"]
  include:
    functions: ["CreateFile*", "CloseHandle"]
    types: ["IFileDialog"]

A symbol package:win32 already binds is not generated again: the declaration is kept, marked, and re-exported from that package, so an interface from either side is the same type. The generated COM shape follows package:win32 — a class over IUnknown holding a vtable pointer, a chained vtable struct, one asFunction per slot — for the same reason.

Web — the dts driver

The only driver with no upstream generator to lean on, because none exists: the one living TypeScript-to-Dart tool was archived in 2022. A Node sidecar on the TypeScript Compiler API emits a JSON model of the declarations, the driver turns that into the model, and the emitter writes dart:js_interop extension types.

web:
  driver: dts
  deps:
    npm: ["[email protected]"]
  include:
    exports: ["Chart", "ChartConfiguration"]

Everything the type system cannot carry across is marked: an overload set binds as separate members, a generic becomes JSAny?, an inline literal union becomes a Dart enum named after where it was declared, a callback parameter takes a Dart function converted with .toJS. That conversion goes one way only, and every call to it makes a new JavaScript function, which is why a callback coming out of JavaScript stays untyped and carries a marker rather than being quietly wrapped.

Generated web code is compiled by both dart2js and dart2wasm in the test suite, because those two decide what is legal js_interop and the analyzer does not. The bug that check exists for is real: a top-level external member needs its own @JS(), and nothing reports it otherwise.

Documentation

A declaration's own documentation follows it into what is generated from it — the binding, the facade, a bridge — written the way that file's language writes documentation. In a C or Objective-C header a plain // or /* */ comment counts, not only ///, and a comment after a declaration on the same line belongs to that declaration. Javadoc's {@code} and {@link} become Markdown; JSDoc's {@link X} becomes a Dart reference, matched @param tags land in Param.docs, and @returns / @deprecated stay in the member's prose (as Returns … and @Deprecated). Doxygen @param / @returns on C and Objective-C headers split the same way. A Swift class reaches Dart through a wrapper swift2objc writes without comments, so its documentation is taken from the Swift symbol graph instead, with - Parameter … on Param.docs and - Returns: / - Throws: in member prose: the facade and bindsmith dump carry it, while the Objective-C binding underneath has only what ffigen found. Kotlin KDoc is read from .kt sources configured on JvmDriver.kotlinSources (jnigen's summarizer does not carry it). ffigen still drops the comments on macros and opaque structs.

Sharing one entry between platforms

inherit: copies another platform's whole configuration rather than repeating it, and a platform that inherits takes nothing else:

platforms:
  macos:
    driver: objc
    headers: [third_party/Example.framework/Headers/Example.h]
  ios:
    inherit: macos

The platform inherited from has to be configured directly. Inheriting from a platform that itself inherits is refused, with the line and column of the inherit: that did it.