What is Tauri
The Tauri open-source community also provides a CLI tool, available to both Rust and JS developers to help them scaffold out, develop, and bundle their Tauri applications. Furthermore, Tauri provides a self-verifying updater system so that users of apps built with Tauri can have a seamless updating experience. Additionally, since cross-compilation is not feasible for all operating systems, there is also an official GitHub Action that allows for building and bundling for the major platforms.
With the upcoming 2.0 stable release later this year, both Android and iOS will become first-class citizens of the Tauri ecosystem, and this article will talk about one of the hardest parts of “going mobile”: Getting access to and using system-level interfaces.
iJS Newsletter
Keep up with JavaScript’s latest news!
Tauri mobile plugins
The biggest challenge of bringing Tauri and its Rust core to mobile was interoperability with existing Android and iOS interfaces. The Rust ecosystem is mainly focused on desktop right now, so mobile is often a second-class citizen and an afterthought for library writers.
Initially, the Tauri R&D team tried jni-rs and objc to write code leveraging the Android and iOS platform APIs, respectively. This was proven unsustainable as writing Java code using JNI or iOS calls using Objective-C requires a lot of boilerplate. We needed a solution that allowed us to write actual Java (or the modern Kotlin language) and Swift code to use the mobile APIs in an idiomatic way, something the community could understand and research.
For iOS we managed to find swift-rs, a library that helps us compile and link a Swift package on the Rust binary, exposing its APIs using FFI. Initially, only macOS was supported, so we opened a pull request to bring the iOS target. All we had to do was tweak the build script to detect iOS and Simulator targets and link the clang_rt library to support the #available attribute.
To call Java code from Rust, we had to stick with jni-rs since JNI is the only way to interact with Java from other languages. Using this Java interface would be really complicated for Tauri users, so we wrote the plugin interfaces in a way where only Tauri core needs to interact with JNI, so developers only need to write Java code, and we handle the glue logic. Let’s see how a Tauri plugin works.
Setting up a new plugin
To bootstrap a new plugin, first, we need to install the 2.0 Tauri CLI. To use the CLI as a Cargo subcommand, run cargo install tauri-cli –version “^2.0.0-alpha”, and the CLI will be accessible with cargo tauri [COMMAND]. Tauri also offers a Node.js-based CLI via napi-rs; to install it locally using NPM, run npm install @tauri-apps/cli@next and use the CLI with npm run tauri [COMMAND].
With the CLI ready, we can initialize a new Tauri plugin project by running the tauri plugin init –name <PLUGIN_NAME>. In this case, the plugin name will be dialog, and the Android package ID prompted by the CLI will be com.plugin.dialog.
Android plugin
A Tauri Android plugin is just a simple Android Studio library package that defines a Java (or Kotlin) class with the @app.tauri.annotation.TauriPlugin annotation. Each command handler is a function with the @app.tauri.annotation.Command annotation. We also provide lifecycle hooks, permission checks, and activity result callbacks. Let’s define an Android plugin that can render native dialogs for alerts, confirmations, and prompts:
package com.plugin.dialog
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.EditText
import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
@TauriPlugin
class DialogPlugin(private val activity: Activity): Plugin(activity) {
// add plugin commands here
}
Now let’s add some commands. First, the alert command. Initially, we read the input arguments and validate them,
@Command
fun alert(invoke: Invoke) {
val title = invoke.getString("title")
val message = invoke.getString("message")
val buttonTitle = invoke.getString("buttonTitle", "OK")
if (message == null) {
invoke.reject("The `message` argument is required")
return
}
if (activity.isFinishing) {
invoke.reject("App is finishing")
return
}
// TODO: render dialog
}
To show the dialog we must use the android.app.AlertDialog API. We use Handler(Looper.getMainLooper()).post to run the dialog in the main thread:
Handler(Looper.getMainLooper())
.post {
val builder = AlertDialog.Builder(activity)
if (title != null) {
builder.setTitle(title)
}
builder
.setMessage(message)
.setPositiveButton(
buttonTitle
) { dialog, _ ->
dialog.dismiss()
invoke.resolve()
}
.setOnCancelListener { dialog ->
dialog.dismiss()
invoke.resolve()
}
val dialog = builder.create()
dialog.show()
}
The confirm command has a similar implementation, including a cancel button argument:
@Command
fun confirm(invoke: Invoke) {
val title = invoke.getString("title")
val message = invoke.getString("message")
val okButtonTitle = invoke.getString("okButtonTitle", "OK")
val cancelButtonTitle = invoke.getString("cancelButtonTitle", "Cancel")
if (message == null) {
invoke.reject("The `message` argument is required")
return
}
if (activity.isFinishing) {
invoke.reject("App is finishing")
return
}
// TODO: render dialog
}
The implementation of the confirm dialog includes a setNegativeButton call to show a cancel button and the invoke.resolve() call includes a response object, defined in the handler callback lambda expression:
val handler = { value: Boolean, cancelled: Boolean ->
val ret = JSObject()
ret.put("value", value)
ret.put("cancelled", cancelled)
invoke.resolve(ret)
}
Handler(Looper.getMainLooper())
.post {
val builder = AlertDialog.Builder(activity)
if (title != null) {
builder.setTitle(title)
}
builder
.setMessage(message)
.setPositiveButton(
okButtonTitle
) { dialog, _ ->
dialog.dismiss()
handler(true, false)
}
.setNegativeButton(
cancelButtonTitle
) { dialog, _ ->
dialog.dismiss()
handler(false, false)
}
.setOnCancelListener { dialog ->
dialog.dismiss()
handler(false, true)
}
val dialog = builder.create()
dialog.show()
}
And lastly, the prompt command code, which extends the confirm command logic with an input textfield:
@Command
fun prompt(invoke: Invoke) {
val title = invoke.getString("title")
val message = invoke.getString("message")
val okButtonTitle = invoke.getString("okButtonTitle", "OK")
val cancelButtonTitle = invoke.getString("cancelButtonTitle", "Cancel")
val inputPlaceholder = invoke.getString("inputPlaceholder", "")
val inputText = invoke.getString("inputText", "")
if (message == null) {
invoke.reject("The `message` argument is required")
return
}
if (activity.isFinishing) {
invoke.reject("App is finishing")
return
}
// TODO: render dialog
}
The prompt dialog code creates an EditText and adds it to the AlertDialog with the setView method.
val handler = { cancelled: Boolean, inputValue: String? ->
val ret = JSObject()
ret.put("cancelled", cancelled)
ret.put("value", inputValue ?: "")
invoke.resolve(ret)
}
Handler(Looper.getMainLooper())
.post {
val builder = AlertDialog.Builder(activity)
val input = EditText(activity)
input.hint = inputPlaceholder
input.setText(inputText)
if (title != null) {
builder.setTitle(title)
}
builder
.setMessage(message)
.setView(input)
.setPositiveButton(
okButtonTitle
) { dialog, _ ->
dialog.dismiss()
handler(false, input.text.toString().trim())
}
.setNegativeButton(
cancelButtonTitle
) { dialog, _ ->
dialog.dismiss()
handler(true, null)
}
.setOnCancelListener { dialog ->
dialog.dismiss()
handler(true, null)
}
val dialog = builder.create()
dialog.show()
}
Each command basically reads input arguments with invoke.getString and asynchronously renders the associated dialog, waiting for the user interaction to call invoke.resolve.
EVERYTHING AROUND ANGULAR
The iJS Angular track
iOS plugin
An iOS plugin is defined as a Swift package defining a plugin class inheriting from Tauri.Plugin. Here is the initial code:
import UIKit
import WebKit
import Tauri
import SwiftRs
class DialogPlugin: Plugin {
// add plugin commands here
}
@_cdecl("init_plugin_dialog")
func initPlugin(name: SRString, webview: WKWebView?) {
Tauri.registerPlugin(webview: webview, name: name.toString(), plugin: DialogPlugin())
}
Each iOS plugin command is an Objective-C function with the following signature: @objc public func commandName( invoke: Invoke)_. Let’s create the alert command:
@objc public func alert(_ invoke: Invoke) {
let manager = self.manager
let title = invoke.getString("title")
guard let message = invoke.getString("message") else {
invoke.reject("The `message` argument is required")
return
}
let buttonTitle = invoke.getString("buttonTitle") ?? "OK"
// TODO: render dialog
}
Similar to the Android plugin, we must dispatch the code to run in the main thread, using DispatchQueue.main.async. To create the dialog, we create a new UIAlertController instance and call manager.viewController.present:
DispatchQueue.main.async { [weak self] in
let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert)
alert.addAction(UIAlertAction(title: buttonTitle, style: UIAlertAction.Style.default, handler: { (_) -> Void in
invoke.resolve()
}))
manager.viewController?.present(alert, animated: true, completion: nil)
}
The confirm command has a similar structure:
@objc public func confirm(_ invoke: Invoke) {
let manager = self.manager
let title = invoke.getString("title")
guard let message = invoke.getString("message") else {
invoke.reject("The `message` argument is required")
return
}
let okButtonTitle = invoke.getString("okButtonTitle") ?? "OK"
let cancelButtonTitle = invoke.getString("cancelButtonTitle") ?? "Cancel"
// TODO: render dialog
}
The confirm dialog render code is similar to the alert implementation, but it includes an addAction call for the cancel button and the response includes a value field with the boolean flag representing the user selection:
DispatchQueue.main.async { [weak self] in
let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert)
alert.addAction(UIAlertAction(title: cancelButtonTitle, style: UIAlertAction.Style.default, handler: { (_) -> Void in
invoke.resolve([
"value": false
])
}))
alert.addAction(UIAlertAction(title: okButtonTitle, style: UIAlertAction.Style.default, handler: { (_) -> Void in
invoke.resolve([
"value": true
])
}))
manager.viewController?.present(alert, animated: true, completion: nil)
}
Lastly, the prompt command includes the UIAlertController::addTextField call to let the user type something:
@objc public func prompt(_ invoke: Invoke) {
let manager = self.manager
let title = invoke.getString("title")
guard let message = invoke.getString("message") else {
invoke.reject("The `message` argument is required")
return
}
let okButtonTitle = invoke.getString("okButtonTitle") ?? "OK"
let cancelButtonTitle = invoke.getString("cancelButtonTitle") ?? "Cancel"
let inputPlaceholder = invoke.getString("inputPlaceholder") ?? ""
let inputText = invoke.getString("inputText") ?? ""
// TODO: render dialog
}
The prompt dialog extends the confirm implementation with the text field by using the alert.addTextField method. The response value is now a string representing the text typed by the user:
DispatchQueue.main.async { [weak self] in
let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert)
alert.addTextField { (textField) in
textField.placeholder = inputPlaceholder
textField.text = inputText
}
alert.addAction(UIAlertAction(title: cancelButtonTitle, style: UIAlertAction.Style.default, handler: { (_) -> Void in
invoke.resolve([
"value": "",
"cancelled": true
])
}))
alert.addAction(UIAlertAction(title: okButtonTitle, style: UIAlertAction.Style.default, handler: { (_) -> Void in
let textField = alert.textFields?[0]
invoke.resolve([
"value": textField?.text ?? "",
"cancelled": false
])
}))
manager.viewController?.present(alert, animated: true, completion: nil)
}
The iOS dialog plugin leverages the self.manager.viewController UIViewController exposed by the Rust core crate to render each UIKit alert dialog. The Invoke API is the same for Android and iOS, making both classes work similarly.
Conclusion
In this article, you learned how the Tauri plugin system for mobile was designed and got the inspiration to write your own Android and iOS plugins with our example dialog project. If you want to know more about it or need help, don’t hesitate to join our community Discord server, visit the Github repository, or just dive in with:
npx create-tauri-app@latest
Discord: https://dicord.gg/tauri
GitHub: https://github.com/tauri-apps/tauri
Twitter: https://twitter.com/TauriApps