---
title: "Tauri plugins, mobile builds and the updater · vuejs.school"
description: "Add official Tauri plugins, grant them per window, call them from Vue through typed wrappers, run on Android and iOS, and ship signed desktop updates."
canonical_url: "https://vuejs.school/community/blog/tauri-plugins-mobile-builds-and-the-updater"
meta:
  "og:description": "Add official Tauri plugins, grant them per window, call them from Vue through typed wrappers, run on Android and iOS, and ship signed desktop updates."
  "og:image": "https://vuejs.school/_og/d/a_~VGF1cmkgcGx1Z2lucywgbW9iaWxlIGJ1aWxkcyBhbmQgdGhlIHVwZGF0ZXIgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~VGF1cmkgcGx1Z2lucywgbW9iaWxlIGJ1aWxkcyBhbmQgdGhlIHVwZGF0ZXI%2Cdescription_~QWRkIG9mZmljaWFsIFRhdXJpIHBsdWdpbnMsIGdyYW50IHRoZW0gcGVyIHdpbmRvdywgY2FsbCB0aGVtIGZyb20gVnVlIHRocm91Z2ggdHlwZWQgd3JhcHBlcnMsIHJ1biBvbiBBbmRyb2lkIGFuZCBpT1MsIGFuZCBzaGlwIHNpZ25lZCBkZXNrdG9wIHVwZGF0ZXMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy90YXVyaS1wbHVnaW5zLW1vYmlsZS1idWlsZHMtYW5kLXRoZS11cGRhdGVyIg%2Cs_YkAUCOWMbpQxQ4wp.png"
  "og:image:alt": "Tauri plugins, mobile builds and the updater · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Tauri plugins, mobile builds and the updater · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~VGF1cmkgcGx1Z2lucywgbW9iaWxlIGJ1aWxkcyBhbmQgdGhlIHVwZGF0ZXIgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~VGF1cmkgcGx1Z2lucywgbW9iaWxlIGJ1aWxkcyBhbmQgdGhlIHVwZGF0ZXI%2Cdescription_~QWRkIG9mZmljaWFsIFRhdXJpIHBsdWdpbnMsIGdyYW50IHRoZW0gcGVyIHdpbmRvdywgY2FsbCB0aGVtIGZyb20gVnVlIHRocm91Z2ggdHlwZWQgd3JhcHBlcnMsIHJ1biBvbiBBbmRyb2lkIGFuZCBpT1MsIGFuZCBzaGlwIHNpZ25lZCBkZXNrdG9wIHVwZGF0ZXMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy90YXVyaS1wbHVnaW5zLW1vYmlsZS1idWlsZHMtYW5kLXRoZS11cGRhdGVyIg%2Cs_YkAUCOWMbpQxQ4wp.png"
  "twitter:image:alt": "Tauri plugins, mobile builds and the updater · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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](https://tauri.app/security/capabilities/) 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](https://tauri.app/start/prerequisites/):

- **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](https://tauri.app/plugin/updater/), 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 stuck**3 questions from the Tauri plugins, mobile and updates quiz.

[**Practice now**](https://vuejs.school/learn/multiplatform/tauri-plugins-and-mobile?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 7 min read**Mobile UI with Ionic Vue 8**](https://vuejs.school/community/blog/mobile-ui-with-ionic-vue-8) [Tutorial · 6 min read**Desktop apps with Tauri 2 and Vue**](https://vuejs.school/community/blog/desktop-apps-with-tauri-2-and-vue)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/tauri-plugins-mobile-builds-and-the-updater#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-07-02","description":"Add official Tauri plugins, grant them per window, call them from Vue through typed wrappers, run on Android and iOS, and ship signed desktop updates.","name":"Tauri plugins, mobile builds and the updater","url":"https://vuejs.school/community/blog/tauri-plugins-mobile-builds-and-the-updater","breadcrumb":{"@id":"https://vuejs.school/community/blog/tauri-plugins-mobile-builds-and-the-updater#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/tauri-plugins-mobile-builds-and-the-updater"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/tauri-plugins-mobile-builds-and-the-updater#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-07-02","description":"Add official Tauri plugins, grant them per window, call them from Vue through typed wrappers, run on Android and iOS, and ship signed desktop updates.","headline":"Tauri plugins, mobile builds and the updater","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~VGF1cmkgcGx1Z2lucywgbW9iaWxlIGJ1aWxkcyBhbmQgdGhlIHVwZGF0ZXIgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~VGF1cmkgcGx1Z2lucywgbW9iaWxlIGJ1aWxkcyBhbmQgdGhlIHVwZGF0ZXI%2Cdescription_~QWRkIG9mZmljaWFsIFRhdXJpIHBsdWdpbnMsIGdyYW50IHRoZW0gcGVyIHdpbmRvdywgY2FsbCB0aGVtIGZyb20gVnVlIHRocm91Z2ggdHlwZWQgd3JhcHBlcnMsIHJ1biBvbiBBbmRyb2lkIGFuZCBpT1MsIGFuZCBzaGlwIHNpZ25lZCBkZXNrdG9wIHVwZGF0ZXMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy90YXVyaS1wbHVnaW5zLW1vYmlsZS1idWlsZHMtYW5kLXRoZS11cGRhdGVyIg%2Cs_YkAUCOWMbpQxQ4wp.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/tauri-plugins-mobile-builds-and-the-updater#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/tauri-plugins-mobile-builds-and-the-updater#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/tauri-plugins-mobile-builds-and-the-updater#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Tauri plugins, mobile builds and the updater","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~VGF1cmkgcGx1Z2lucywgbW9iaWxlIGJ1aWxkcyBhbmQgdGhlIHVwZGF0ZXIgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~VGF1cmkgcGx1Z2lucywgbW9iaWxlIGJ1aWxkcyBhbmQgdGhlIHVwZGF0ZXI%2Cdescription_~QWRkIG9mZmljaWFsIFRhdXJpIHBsdWdpbnMsIGdyYW50IHRoZW0gcGVyIHdpbmRvdywgY2FsbCB0aGVtIGZyb20gVnVlIHRocm91Z2ggdHlwZWQgd3JhcHBlcnMsIHJ1biBvbiBBbmRyb2lkIGFuZCBpT1MsIGFuZCBzaGlwIHNpZ25lZCBkZXNrdG9wIHVwZGF0ZXMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy90YXVyaS1wbHVnaW5zLW1vYmlsZS1idWlsZHMtYW5kLXRoZS11cGRhdGVyIg%2Cs_YkAUCOWMbpQxQ4wp.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~VGF1cmkgcGx1Z2lucywgbW9iaWxlIGJ1aWxkcyBhbmQgdGhlIHVwZGF0ZXIgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~VGF1cmkgcGx1Z2lucywgbW9iaWxlIGJ1aWxkcyBhbmQgdGhlIHVwZGF0ZXI%2Cdescription_~QWRkIG9mZmljaWFsIFRhdXJpIHBsdWdpbnMsIGdyYW50IHRoZW0gcGVyIHdpbmRvdywgY2FsbCB0aGVtIGZyb20gVnVlIHRocm91Z2ggdHlwZWQgd3JhcHBlcnMsIHJ1biBvbiBBbmRyb2lkIGFuZCBpT1MsIGFuZCBzaGlwIHNpZ25lZCBkZXNrdG9wIHVwZGF0ZXMu%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy90YXVyaS1wbHVnaW5zLW1vYmlsZS1idWlsZHMtYW5kLXRoZS11cGRhdGVyIg%2Cs_YkAUCOWMbpQxQ4wp.png"}]}
```