owned this note
owned this note
Published
Linked with GitHub
# Tauri 2.0: Going Mobile
Lucas Nogueira & Daniel Thompson-Yvetot
Cofounders of Tauri
https://tauri.app
## What is Tauri
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 webview and the IPC bridge between the webview 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 webview via JS APIs that send and receive messages across the IPC.
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 seemless 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.
## 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 a idiomatic way, something the community could understand and research.
For iOS we managed to find [swift-rs](https://github.com/Brendonovich/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`](https://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:
```kotlin
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,
```kotlin
@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:
```kotlin
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:
```kotlin
@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:
```kotlin
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:
```kotlin
@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.
```kotlin
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`.
### iOS plugin
An iOS plugin is defined as a Swift package defining a plugin class inheriting from `Tauri.Plugin`. Here is the initial code:
```swift
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:
```swift
@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`:
```swift
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:
```swift
@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:
```swift
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:
```swift
@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:
```swift
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 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