Using Capacitor Plugins from Rust/WASM

Christian Kjær in a casual setting :)
Christian Kjær
7 min read


24. June 2024


, , ,

This is a part of the post Mobile: A different way using Rust?.

Capacitor is normally used in combination with JavaScript projects, so there is little help available if you want to use it in a Rust/WASM project. Worry not! That's exactly what we'll take a look at in this post.

Since Capacitor is a JS framework there will be a little extra work involved in interacting with any plugins, but it’s honestly not a lot we need to do. Let’s take a quick tour through what I’ve set up in the example repository

We've set up four crates:

  • appy: Our Leptos App, Capacitor, and the XCode project
  • capacitor-rs: Bridging code between the Capacitor JS library and our Rust code
  • shared: Our shared code that we might use in appy, and also want to expose in Swift to use in our Widgets or watchOS App
  • mobile: Where we will generate the Swift bindings from via UniFFI, reexporting everything from shared that’s made available to UniFFI via the macros

In this post we'll focus on the capacitor-rs and appy crates, which contain our Capacitor bridge and our app where we'll use it respectively.

An overview of what we'll cover:

Setting up capacitor-rs

First, let’s create a new library where we’ll keep the Capacitor bridge related code in:

cargo new --lib capacitor-rs

We’ll also initialize an empty TypeScript project using bun which we’ll use to install the Capacitor dependencies and bundle our TypeScript files:

$ bun init
bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit

package name (appy): capacitor-wrapper
entry point (index.ts): index.ts

We’ll then create a few new folders to host our code in (assuming you’re in the ./capacitor-rs directory):

# We'll expose an interface for Capacitor from JS here.
mkdir js
# We'll keep our bundled JS files here.
mkdir js-dist
# We'll keep the corresponding Rust files here.
mkdir src/plugins

Let’s set up our capacitor-rs/src/ to expose the plugins folder:

1pub mod plugins;

And finally, let’s update our dependencies of the Rust project in ./capacitor-rs/Cargo.toml:

2name = "capacitor-rs"
3version = "0.1.0"
4edition = "2021"
7# Match Trunk's wasm-bindgen version for compatability.
8wasm-bindgen = { version = "=0.2.87" }
9# Used for working with async/promises in JS.
10wasm-bindgen-futures = { version = "0.4" }
11js-sys = { version = "0.3" }

Bridging a CapacitorJS plugin

For this example we’ll set up the Haptics plugin, which will allow us to provide tactile feedback from our App in response to user interactions.

Our approach will be:

  1. Install the capacitor plugin in both the ./appy and ./capacitor projects
    1. ./appy: Capacitor needs access to the plugins when packaging our App
    2. ./capacitor-rs: We’ll need access to the libraries to be able to bundle them with bun into a single file since wasm_bindgen does not support imports
  2. Add a TypeScript file that imports the plugin and exports functions to interact with it
  3. Bundle the TypeScript file using bun to create a single file without any imports in it
  4. Add a Rust file that uses the wasm_bindgen proc macro to create a Rust interface for the TypeScript functions we’ve defined

Let’s add the TypeScript side of things in ./js/haptics.ts which quite simply just wraps and exposes the Capacitor functionality:

2 *
3 */
4import type { ImpactOptions } from "@capacitor/haptics";
5import { Haptics, ImpactStyle } from "@capacitor/haptics";
7export const haptics_impact = async (options?: ImpactOptions | undefined) => {
8 await Haptics.impact(options);
11export const haptics_impact_medium = async () => {
12 await Haptics.impact({ style: ImpactStyle.Medium });
15export const haptics_impact_light = async () => {
16 await Haptics.impact({ style: ImpactStyle.Light });
19export const haptics_vibrate = async () => {
20 await Haptics.vibrate();
23export const haptics_selection_start = async () => {
24 await Haptics.selectionStart();
27export const haptics_selection_changed = async () => {
28 await Haptics.selectionChanged();
31export const haptics_selection_end = async () => {
32 await Haptics.selectionEnd();

We then bundle this with bun:

bun build --target browser --minify --outdir js-dist js/**

Which gives us a corresponding bundled file in js-dist/haptics.js that we can use.

As the final step, we’ll bridge this into our Rust code by setting up src/plugins/

1use wasm_bindgen::prelude::*;
4pub struct ImpactOptions {
5 /// Impact Feedback Style
6 ///
7 /// The mass of the objects in the collision simulated by a [UIImpactFeedbackGenerator]( object.
8 ///
9 /// @default ImpactStyle.Heavy
10 /// @since 1.0.0
11 pub style: ImpactStyle,
15#[derive(Clone, Copy)]
16pub enum ImpactStyle {
17 /// A collision between large, heavy user interface elements
18 ///
19 /// @since 1.0.0
20 Heavy,
21 /// A collision between moderately sized user interface elements
22 ///
23 /// @since 1.0.0
24 Medium,
25 /// A collision between small, light user interface elements
26 ///
27 /// @since 1.0.0
28 Light,
31impl ImpactStyle {
32 pub fn as_str(&self) -> &'static str {
33 match self {
34 ImpactStyle::Heavy => "HEAVY",
35 ImpactStyle::Medium => "MEDIUM",
36 ImpactStyle::Light => "LIGHT",
37 }
38 }
41#[wasm_bindgen(module = "/js-dist/haptics.js")]
42extern "C" {
43 // #[wasm_bindgen(typescript_type = "ImpactOptions")]
44 // pub type ImpactOptions;
46 /// Trigger a haptics "impact" feedback.
47 #[wasm_bindgen]
48 pub async fn haptics_impact(options: ImpactOptions);
50 #[wasm_bindgen]
51 /// Trigger a haptics medium "impact" feedback.
52 pub async fn haptics_impact_medium();
54 #[wasm_bindgen]
55 /// Trigger a haptics light "impact" feedback.
56 pub async fn haptics_impact_light();
58 #[wasm_bindgen]
59 /// Vibrate the device.
60 pub async fn haptics_vibrate();
62 #[wasm_bindgen]
63 /// Trigger a selection started haptic hint.
64 pub async fn haptics_selection_start();
66 #[wasm_bindgen]
67 /// Trigger a selection changed haptic hint. If a selection was started already, this
68 /// will cause the device to provide haptic feedback.
69 pub async fn haptics_selection_changed();
71 #[wasm_bindgen]
72 /// If selectionStart() was called, selectionEnd() ends the selection. For example,
73 /// call this when a user has lifted their finger from a control.
74 pub async fn haptics_selection_end();

Let’s break down what’s going on here:

  • We mirror some of the types from the Capacitor plugin library into Rust to ImpactOptions, ImpactStyle (unfortunately wasm_bindgen doesn’t support generating these for us, but it’s more or less a copy-paste of the TypeScript code)
  • We point to our generated JavaScript file #[wasm_bindgen(module = "/js-dist/haptics.js")] which will ensure it gets included automatically
  • We setup the type signatures matching each of the TypeScript functions we defined and that we want to include

We also need to add a src/plugins/ file to expose our new plugin:

1pub mod haptics;

Calling it from our App

We’re now ready to use it in our App by adding the new capacitor-rs crate to our dependencies in our WASM app:

1# ...
3capacitor-rs = { version = "0.1.0", path = "../capacitor-rs" }
4# ...other dependencies

And then using it like you like a normal Rust function:

1use log::info;
2use capacitor_rs::plugins::haptics;
3use leptos::*;
7pub fn Page() -> impl IntoView {
8 let haptic_feedback = create_action(|_: &()| {
9 info!("Testing haptic_feedback");
10 async move {
11 let style = haptics::ImpactStyle::Light;
12 haptics::haptics_impact(haptics::ImpactOptions {
13 style: style.into(),
14 })
15 }
16 });
17 view! {
18 <span on:click=move |_| { haptic_feedback.dispatch(()) }>
19 Click to test haptic feedback
20 </span>
21 }

And that’s it!

Since I can’t exactly screenshot a haptic vibration, here’s an example where we use the Capacitor utility function to determine which platform we are on, set up in the same way:

Screenshot of Capacitor running on iPhone
Screenshot of Capacitor running in Web
👉 Let me know what you think over on Medium or in the comments below 👇