Shared core and types

These are the steps to set up the two crates forming the shared core – the core itself, and the shared types crate which does type generation for the foreign languages.

Sharp edge

Most of these steps are going to be automated in future tooling, and published as crates. For now the set up is effectively a copy & paste from one of the example projects.

Install the tools

This is an example of a rust-toolchain.toml file, which you can add at the root of your repo. It should ensure that the correct rust channel and compile targets are installed automatically for you when you use any rust tooling within the repo.

[toolchain]
channel = "stable"
components = ["rustfmt", "rustc-dev"]
targets = [
  "aarch64-apple-darwin",
  "aarch64-apple-ios",
  "aarch64-apple-ios-sim",
  "aarch64-linux-android",
  "wasm32-unknown-unknown",
  "x86_64-apple-ios"
]
profile = "minimal"

Create the core crate

The shared library

The first library to create is the one that will be shared across all platforms, containing the behavior of the app. You can call it whatever you like, but we have chosen the name shared here. You can create the shared rust library, like this:

cargo new --lib shared

The workspace and library manifests

We'll be adding a bunch of other folders into the monorepo, so we are choosing to use Cargo Workspaces. Edit the workspace /Cargo.toml file, at the monorepo root, to add the new library to our workspace. It should look something like this:

[workspace]
members = ["shared"]
resolver = "1"

[workspace.package]
authors = ["Red Badger Consulting Limited"]
edition = "2021"
repository = "https://github.com/redbadger/crux/"
license = "Apache-2.0"
keywords = ["crux", "crux_core", "cross-platform-ui", "ffi", "wasm"]
rust-version = "1.66"

[workspace.dependencies]
anyhow = "1.0.79"
crux_core = "0.7"
serde = "1.0.196"

The library's manifest, at /shared/Cargo.toml, should look something like the following, but there are a few things to note:

  • the crate-type
    • lib is the default rust library when linking into a rust binary, e.g. in the web-yew, or cli, variant
    • staticlib is a static library (libshared.a) for including in the Swift iOS app variant
    • cdylib is a C-ABI dynamic library (libshared.so) for use with JNA when included in the Kotlin Android app variant
  • we need to declare a feature called typegen that depends on the feature with the same name in the crux_core crate. This is used by this crate's sister library (often called shared_types) that will generate types for use across the FFI boundary (see the section below on generating shared types).
  • the uniffi dependencies and uniffi-bindgen target should make sense after you read the next section
[package]
name = "shared"
version = "0.1.0"
edition = "2021"
rust-version = "1.66"

[lib]
crate-type = ["lib", "staticlib", "cdylib"]
name = "shared"

[features]
typegen = ["crux_core/typegen"]

[dependencies]
crux_core.workspace = true
serde = { workspace = true, features = ["derive"] }
lazy_static = "1.5.0"
uniffi = "0.28.0"
wasm-bindgen = "0.2.92"

[target.uniffi-bindgen.dependencies]
uniffi = { version = "0.28.0", features = ["cli"] }

[build-dependencies]
uniffi = { version = "0.28.0", features = ["build"] }

FFI bindings

Crux uses Mozilla's Uniffi to generate the FFI bindings for iOS and Android.

Generating the uniffi-bindgen CLI tool

Since Mozilla released version 0.23.0 of Uniffi, we need to also generate the binary that generates these bindings. This avoids the possibility of getting a version mismatch between a separately installed binary and the crate's Uniffi version. You can read more about it here.

Generating the binary is simple, we just add the following to our crate, in a file called /shared/src/bin/uniffi-bindgen.rs.

fn main() {
    uniffi::uniffi_bindgen_main()
}

And then we can build it with cargo.

cargo run -p shared --bin uniffi-bindgen

# or

cargo build
./target/debug/uniffi-bindgen

The uniffi-bindgen executable will be used during the build in XCode and in Android Studio (see the following pages).

The interface definitions

We will need an interface definition file for the FFI bindings. Uniffi has its own file format (similar to WebIDL) that has a .udl extension. You can create one at /shared/src/shared.udl, like this:

namespace shared {
  bytes process_event([ByRef] bytes msg);
  bytes handle_response(u32 id, [ByRef] bytes res);
  bytes view();
};

There are also a few additional parameters to tell Uniffi how to create bindings for Kotlin and Swift. They live in the file /shared/uniffi.toml, like this (feel free to adjust accordingly):

[bindings.kotlin]
package_name = "com.example.simple_counter.shared"
cdylib_name = "shared"

[bindings.swift]
cdylib_name = "shared_ffi"
omit_argument_labels = true

Finally, we need a build.rs file in the root of the crate (/shared/build.rs), to generate the bindings:

fn main() {
    uniffi::generate_scaffolding("./src/shared.udl").unwrap();
}

Scaffolding

Soon we will have macros and/or code-gen to help with this, but for now, we need some scaffolding in /shared/src/lib.rs. You'll notice that we are re-exporting the Request type and the capabilities we want to use in our native Shells, as well as our public types from the shared library.

pub mod app;

use lazy_static::lazy_static;
use wasm_bindgen::prelude::wasm_bindgen;

pub use crux_core::{bridge::Bridge, Core, Request};

pub use app::*;

// TODO hide this plumbing

uniffi::include_scaffolding!("shared");

lazy_static! {
    static ref CORE: Bridge<Effect, Counter> = Bridge::new(Core::new());
}

#[wasm_bindgen]
pub fn process_event(data: &[u8]) -> Vec<u8> {
    CORE.process_event(data)
}

#[wasm_bindgen]
pub fn handle_response(id: u32, data: &[u8]) -> Vec<u8> {
    CORE.handle_response(id, data)
}

#[wasm_bindgen]
pub fn view() -> Vec<u8> {
    CORE.view()
}

The app

Now we are in a position to create a basic app in /shared/src/app.rs. This is from the simple Counter example (which also has tests, although we're not showing them here):

use crux_core::{render::Render, App};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum Event {
    Increment,
    Decrement,
    Reset,
}

#[derive(Default)]
pub struct Model {
    count: isize,
}

#[derive(Serialize, Deserialize, Clone, Default)]
pub struct ViewModel {
    pub count: String,
}

#[cfg_attr(feature = "typegen", derive(crux_core::macros::Export))]
#[derive(crux_core::macros::Effect)]
pub struct Capabilities {
    render: Render<Event>,
}

#[derive(Default)]
pub struct Counter;

impl App for Counter {
    type Event = Event;
    type Model = Model;
    type ViewModel = ViewModel;
    type Capabilities = Capabilities;

    fn update(&self, event: Self::Event, model: &mut Self::Model, caps: &Self::Capabilities) {
        match event {
            Event::Increment => model.count += 1,
            Event::Decrement => model.count -= 1,
            Event::Reset => model.count = 0,
        };

        caps.render.render();
    }

    fn view(&self, model: &Self::Model) -> Self::ViewModel {
        ViewModel {
            count: format!("Count is: {}", model.count),
        }
    }
}

Make sure everything builds OK

cargo build

Create the shared types crate

This crate serves as the container for type generation for the foreign languages.

  • Copy over the shared_types folder from the counter example.

  • Add the shared types crate to the workspace in the /Cargo.toml file at the monorepo root. It should look something like this:

[workspace]
members = ["shared", "shared_types"]
resolver = "1"

[workspace.package]
authors = ["Red Badger Consulting Limited"]
edition = "2021"
repository = "https://github.com/redbadger/crux/"
license = "Apache-2.0"
keywords = ["crux", "crux_core", "cross-platform-ui", "ffi", "wasm"]
rust-version = "1.66"

[workspace.dependencies]
anyhow = "1.0.79"
crux_core = "0.7"
serde = "1.0.196"
  • Edit the build.rs file and make sure that your app type is registered. In our example, the app type is Counter, so make sure you include this statement in your build.rs
 gen.register_app::<Counter>()?;

The build.rs file should now look like this:

use crux_core::typegen::TypeGen;
use shared::Counter;
use std::path::PathBuf;

fn main() -> anyhow::Result<()> {
    println!("cargo:rerun-if-changed=../shared");

    let mut gen = TypeGen::new();

    gen.register_app::<Counter>()?;

    let output_root = PathBuf::from("./generated");

    gen.swift("SharedTypes", output_root.join("swift"))?;

    gen.java(
        "com.example.simple_counter.shared_types",
        output_root.join("java"),
    )?;

    gen.typescript("shared_types", output_root.join("typescript"))?;

    Ok(())
}

Tip

You may also need to register any nested enum types (due to a current limitation with the reflection library, see https://github.com/zefchain/serde-reflection/tree/main/serde-reflection#supported-features). Here is an example of this from the build.rs file in the shared_types crate of the notes example:

use crux_core::typegen::TypeGen;
use crux_kv::{error::KeyValueError, value::Value, KeyValueResponse};
use shared::{NoteEditor, TextCursor};
use std::path::PathBuf;

fn main() -> anyhow::Result<()> {
    println!("cargo:rerun-if-changed=../shared");

    let mut gen = TypeGen::new();

    gen.register_app::<NoteEditor>()?;

    // Note: currently required as we can't find enums inside enums, see:
    // https://github.com/zefchain/serde-reflection/tree/main/serde-reflection#supported-features
    gen.register_type::<TextCursor>()?;

    // Register types from crux_kv
    // NOTE: in the next version of crux_kv, this will not be necessary
    gen.register_type::<KeyValueResponse>()?;
    gen.register_type::<KeyValueError>()?;
    gen.register_type::<Value>()?;

    let output_root = PathBuf::from("./generated");

    gen.swift("SharedTypes", output_root.join("swift"))?;

    // TODO these are for later
    //
    // gen.java("com.example.counter.shared_types", output_root.join("java"))?;

    gen.typescript("shared_types", output_root.join("typescript"))?;

    Ok(())
}

Tip

For the above to compile, your Capabilities struct must implement the Export trait. There is a derive macro that can do this for you, e.g.:

#[cfg_attr(feature = "typegen", derive(crux_core::macros::Export))]
#[derive(crux_core::macros::Effect)]
pub struct Capabilities {
    render: Render<Event>,
    http: Http<Event>,
}

The Export and Effect derive macros can be configured with the effect attribute if you need to specify a different name for the Effect type e.g.:

#[cfg_attr(feature = "typegen", derive(Export))]
#[derive(Effect)]
#[effect(name = "MyEffect")]
pub struct Capabilities {
    render: Render<Event>,
    pub_sub: PubSub<Event>,
}

Additionally, if you are using a Capability that does not need to be exported to the foreign language, you can use the #[effect(skip)] attribute to skip exporting it, e.g.:

#[cfg_attr(feature = "typegen", derive(Export))]
#[derive(Effect)]
pub struct Capabilities {
    render: Render<Event>,
    #[effect(skip)]
    compose: Compose<Event>,
}
  • Make sure everything builds and foreign types get generated into the generated folder. This step needs pnpm installed and on your $PATH.

    cargo build
    

Success

You should now be ready to set up iOS, Android, or web specific builds.