Skip to content
TutorialMultiplatform

Tauri plugins, mobile builds and the updater

One tauri add per plugin, a capability file that grants it, and a signing key you must never lose. With those, a Tauri 2.11 app goes from desktop only to phones and self-updating installers.

vuejs.school team2 Jul 20267 min readTauri 2.11 · Vue 3.5

A Tauri app starts small on purpose. The core gives you a native window and a way to call Rust. Anything that touches the operating system, such as files, dialogs or notifications, comes from an official plugin that you add yourself and then allow, one permission at a time.

The example is a small notes app made with npm create tauri-app@latest and the Vue TypeScript template. You will give it file export, saved settings and notifications, run it on an Android emulator and an iOS simulator, and ship it to desktop users with updates signed by your own key. The versions are Tauri 2.11 (the tauri crate at 2.11.5, the CLI at 2.11.4, @tauri-apps/api at 2.11.1) and Vue 3.5.

Add the official plugins

Most plugins have two halves: a Rust crate that does the work, and an npm package with the JavaScript bindings your components import. The CLI's add command installs both. The notes app needs five: fs to write files, dialog for the save dialog, notification, store for settings, and os, which tells the frontend which platform it is running on (the updater code needs that).

npm run tauri add fs
npm run tauri add dialog
npm run tauri add notification
npm run tauri add store
npm run tauri add os

Each run makes four changes:

  • Adds the tauri-plugin-<name> crate to src-tauri/Cargo.toml.
  • Installs the @tauri-apps/plugin-<name> npm package.
  • Adds the plugin's default permission, such as fs:default, to your capability files. When there is more than one file, it asks which ones should get it.
  • Inserts a .plugin(...) line into the builder in src-tauri/src/lib.rs.

After the five commands, the builder in lib.rs looks like this. The order of the .plugin calls does not matter.

src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_os::init())
        .plugin(tauri_plugin_store::Builder::new().build())
        .plugin(tauri_plugin_notification::init())
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_opener::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

The store plugin uses a builder because it accepts options. The others only need init(), and the opener line was already in the project.

Grant permissions in a capability file

A plugin does nothing for the webview until a capability allows it. A capability is a JSON file in src-tauri/capabilities/ that names some windows and lists the permissions they get. The capabilities guide covers the full format.

The default permissions are narrow on purpose. fs:default lets the app create and read its own data folders and nothing else, while dialog:default, notification:default and store:default cover the everyday commands of those plugins. The notes app exports a note to a file the user chooses, so it needs one permission more:

src-tauri/capabilities/default.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "opener:default",
    "fs:default",
    "fs:allow-write-text-file",
    "dialog:default",
    "notification:default",
    "store:default",
    "os:default"
  ]
}

fs:allow-write-text-file allows the command but adds no paths to the file system scope (the list of paths the fs plugin will touch). The path comes from the save dialog: when the user picks a file, the dialog plugin adds that single file to the scope. The write then succeeds for that file, and paths outside the scope are refused. That is the pattern to aim for: give the webview a command, and let the user's own choice decide where it may act.

A capability can also apply to some platforms only, through a platforms array such as ["linux", "macOS", "windows"] or ["iOS", "android"]. You will meet one when you add the updater.

Call the plugins from Vue through typed wrappers

Components should not need to know which plugin does what. Put every native call in one module of small, typed functions:

src/lib/native.ts
import { LazyStore } from '@tauri-apps/plugin-store'
import { save } from '@tauri-apps/plugin-dialog'
import { writeTextFile } from '@tauri-apps/plugin-fs'
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'
import { platform } from '@tauri-apps/plugin-os'

export interface Settings {
  fontSize: number
  lastExport: string | null
}

const settings = new LazyStore('settings.json', {
  defaults: { fontSize: 16, lastExport: null },
  autoSave: 200,
})

export function getSetting<K extends keyof Settings>(key: K) {
  return settings.get<Settings[K]>(key)
}

export async function exportNote(title: string, text: string): Promise<string | null> {
  const path = await save({ defaultPath: `${title}.md`, filters: [{ name: 'Markdown', extensions: ['md'] }] })
  if (!path) return null // the user cancelled
  await writeTextFile(path, text)
  await settings.set('lastExport', new Date().toISOString())
  return path
}

export async function notify(title: string, body: string): Promise<void> {
  let granted = await isPermissionGranted()
  if (!granted) granted = (await requestPermission()) === 'granted'
  if (granted) sendNotification({ title, body })
}

export const isDesktop = ['linux', 'macos', 'windows'].includes(platform())

LazyStore opens settings.json in the app's data folder the first time you read or write, and autoSave: 200 writes changes to disk 200 milliseconds after the last one. In store plugin 2.4 the options object needs a defaults key, so pass one even if it is empty. The notify function handles the permission prompt once, so no component has to. platform() answers without waiting, so isDesktop is a plain boolean, set once when the module loads.

A component then reads like any other Vue code:

src/components/ExportButton.vue
<script setup lang="ts">
import { ref } from 'vue'
import { exportNote, notify } from '../lib/native'

const props = defineProps<{ title: string; text: string }>()
const status = ref('')

async function onExport() {
  try {
    const path = await exportNote(props.title, props.text)
    if (!path) return
    status.value = 'Saved'
    await notify('Note exported', `${props.title} is saved.`)
  } catch (err) {
    status.value = String(err)
  }
}
</script>

<template>
  <button type="button" @click="onExport">Export</button>
  <p v-if="status" role="status">{{ status }}</p>
</template>

Keeping native calls in one module also helps tests: a component test can replace the whole native layer with one vi.mock('../lib/native', () => ({ exportNote: vi.fn(), notify: vi.fn() })) call and run in a plain browser environment with no Tauri at all. Pass that factory: without one, Vitest imports the real module to copy its exports, and platform() throws outside Tauri.

Run it on Android and iOS

Mobile builds need extra tools, listed in the prerequisites guide:

  • Android: Android Studio, with the SDK Platform, Platform-Tools, NDK (Side by side), Build-Tools and Command-line Tools installed from its SDK Manager, and the JAVA_HOME, ANDROID_HOME and NDK_HOME variables set.
  • iOS: a Mac with the full Xcode (the command line tools alone are not enough) and CocoaPods.

Each platform also needs its Rust targets. Then init creates the native project once, and dev builds and runs the app:

rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
npm run tauri android init
npm run tauri android dev

rustup target add aarch64-apple-ios x86_64-apple-ios aarch64-apple-ios-sim
npm run tauri ios init
npm run tauri ios dev

android init writes an Android Studio project to src-tauri/gen/android, and ios init writes an Xcode project to src-tauri/gen/apple. The dev commands look for a connected device first and otherwise ask you to pick an emulator or simulator, so create an emulator in Android Studio's Device Manager before the first run. Vue changes reload on the phone as they do on desktop. To work inside Android Studio or Xcode instead, pass --open after npm's --, as in npm run tauri android dev -- --open.

Your Rust needs no change. The #[cfg_attr(mobile, tauri::mobile_entry_point)] line on run() makes it the entry point on phones, while main.rs only runs on desktop.

To debug the webview, open chrome://inspect in Chrome for an Android emulator, or Safari's Develop menu for the iOS simulator. A store build is one more command, such as npm run tauri android build -- --aab for Google Play.

What differs between desktop and mobile

The code above runs on all five platforms, but a few plugin behaviours change on a phone:

  • Paths. The dialog returns normal file paths on desktop, file:// URIs on iOS and content URIs on Android. The fs plugin accepts all three. A custom Rust command that calls std::fs on an Android content URI fails, which is one more reason to read and write through the plugin.
  • Folders. The dialog has no folder picker on Android or iOS, and the fs plugin is limited to the app's own folders by default on both.
  • Notifications. Actions, the buttons and text fields inside a notification, are mobile only. On Windows, notifications only work for an installed app; in development they show PowerShell's name and icon.
  • One-sided plugins. The updater, global-shortcut, window-state and autostart plugins are desktop only. Barcode-scanner, biometric, nfc and haptics are mobile only. tauri add knows which is which.

Set up the updater and its signing key

Phones get new versions from the App Store and Google Play. A desktop app you distribute yourself needs the updater plugin, and the updater only installs files signed with your key. That check cannot be turned off.

Add the updater and the process plugin (its relaunch() restarts the app after an update), then generate a key pair:

npm run tauri add updater
npm run tauri add process
npm run tauri signer generate -- -w ~/.tauri/notes.key

Because the updater is desktop only, tauri add puts its crate under a desktop target in Cargo.toml and its permission in a new capabilities/desktop.json, limited to Linux, macOS and Windows. It still inserts the .plugin line into the main builder chain, though, so Android and iOS builds stop compiling. Delete that line and register the plugin in the builder's setup hook instead, behind #[cfg(desktop)]:

src-tauri/src/lib.rs
        .setup(|app| {
            #[cfg(desktop)]
            app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
            Ok(())
        })

signer generate writes a private key to ~/.tauri/notes.key and a public key next to it with a .pub ending. Paste the public key's contents into the config, and turn on updater artifacts:

src-tauri/tauri.conf.json
{
  "bundle": { "createUpdaterArtifacts": true },
  "plugins": {
    "updater": {
      "pubkey": "CONTENTS OF ~/.tauri/notes.key.pub",
      "endpoints": ["https://github.com/your-name/notes/releases/latest/download/latest.json"]
    }
  }
}

The endpoints URL is where installed apps look for the update manifest. GitHub's releases/latest/download/ path always serves that file from the latest release.

The private key is the one thing you cannot replace. Lose it and installed copies can never update again. Keep it in a password manager and as a CI secret, never in the repository.

Publish a release and install it from Vue

Every release follows the same four steps:

  1. Raise version in tauri.conf.json.
  2. Build on each operating system with TAURI_SIGNING_PRIVATE_KEY set in the environment (a .env file is not read). Each build writes an update bundle and a .sig signature next to it, such as notes.app.tar.gz.sig on macOS.
  3. Publish a manifest, latest.json, listing the version and each platform's download URL and signature. The official tauri-action for GitHub Actions, run once per operating system, uploads the bundles and this file to the release for you.
  4. Installed apps call check(). It fetches the manifest, offers the update only when the version is newer, and verifies the signature against your public key before installing.

In Vue, the check and install fit in one composable:

src/composables/useUpdater.ts
import { ref, shallowRef } from 'vue'
import { check, type Update } from '@tauri-apps/plugin-updater'
import { relaunch } from '@tauri-apps/plugin-process'
import { isDesktop } from '../lib/native'

export function useUpdater() {
  const update = shallowRef<Update | null>(null)
  const percent = ref(0)

  async function lookForUpdate() {
    if (isDesktop) update.value = await check()
  }

  async function installUpdate() {
    if (!update.value) return
    let total = 0
    let received = 0
    await update.value.downloadAndInstall((event) => {
      if (event.event === 'Started') total = event.data.contentLength ?? 0
      if (event.event === 'Progress') {
        received += event.data.chunkLength
        percent.value = total ? Math.round((received / total) * 100) : 0
      }
    })
    await relaunch()
  }

  return { update, percent, lookForUpdate, installUpdate }
}

Use shallowRef here, not ref. An Update keeps its resource id in a private class field, and a deep reactive proxy around it makes downloadAndInstall throw a "Cannot read private member" error.

On Windows the app closes by itself during installation, so relaunch() only matters on macOS and Linux.

Check it stuck3 questions from the Tauri plugins, mobile and updates quiz.
Practice now
Was this helpful?

Keep reading