iJS CONFERENCE Blog

Tauri 2.0

A look at the Rust-based JS framework

Aug 7, 2023

Tauri is an open-source Rust-based framework for building native-feeling apps for a wide array of operating systems. Generally speaking, Tauri-based apps leverage the system web view and the IPC bridge between the web view and the Rust host, also known as Tauri Core. Tauri provides a number of interfaces to lower-level systems, like file access, and makes these interfaces available to the user interface in a web view via JS APIs that send and receive messages across the IPC.

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

STAY TUNED!

 

BEHIND THE TRACKS OF iJS

JavaScript Practices & Tools

DevOps, Testing, Performance, Toolchain & SEO

Angular

Best-Practises with Angular

General Web Development

Broader web development topics

Node.js

All about Node.js

React

From Basic concepts to unidirectional data flows