Tauri plugins, mobile and updates
Add Tauri plugins for files, dialogs and notifications, allow them per window with capabilities, permissions and scopes, run the same app on Android and iOS, and ship signed updates to the desktop.
What you'll learn
When you finish, you can do these.- 1Add Tauri plugins for files, dialogs and notifications
- 2Limit what plugins may touch with capabilities and scopes
- 3Run the same Tauri app on Android and iOS
- 4Sign the desktop app's updates with your own key
Picture the notes app this track builds, a small Vue app for writing notes, made into a desktop program with Tauri, a tool that adds a small core written in the Rust language. The app saves notes in notes.json, exports a copy wherever the person chooses and shows a notification when the export is done. Tauri offers file access, save dialogs and notifications as ready-made plugins, and a page may use one only after a capability, a JSON file of permissions, allows it.
Here you add those three plugins, limit what they may touch, run the app on a phone and sign its desktop updates. You need one idea from Desktop apps with Tauri: a project has a Vue half in src/ and a Rust half in src-tauri/, whose lib.rs starts the app. The Code tab holds the finished app: its notes code in src/lib/notes.ts and the Rust half's files.
Add a plugin
A plugin has two halves: a Rust package (a crate) that does the work, and an npm package with typed functions for the page. One command adds both, here for the file plugin, fs:
npm run tauri add fs
It adds tauri-plugin-fs to src-tauri/Cargo.toml, the Rust half's package list, and @tauri-apps/plugin-fs to package.json. It also registers the plugin in lib.rs and adds its default permission, fs:default, to the capability file. Run it again for dialog and notification, and lib.rs registers all three as the app starts:
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
The page then calls the plugins' functions directly, with no Rust of your own. loadNotes() in src/lib/notes.ts reads notes.json from the app data folder, the folder the operating system keeps for each app's files:
export async function loadNotes(): Promise<Note[]> {
if (!(await exists('notes.json', { baseDir: BaseDirectory.AppData }))) return []
return JSON.parse(await readTextFile('notes.json', { baseDir: BaseDirectory.AppData }))
}
On the first run exists() finds no file, so loadNotes() returns an empty list.
Capabilities, permissions and scopes
Capabilities limit what the plugins may do: every plugin call passes a check before any Rust code runs. Even a harmful script hidden in a note reaches only what you allowed.
- Vue calls plugin-fs
- capability checkpermission + scope
- Rust plugin
- operating system
Three words describe that check:
- A permission names commands, the plugin functions the page may call, such as
fs:allow-write-text-file. A set such asfs:defaultbundles several. - A scope limits what those commands may touch, such as file paths.
- A capability is a JSON file in
src-tauri/capabilities/that grants permissions to windows by their label, the name each window has intauri.conf.json, the app's settings file. Every file there is active.
The notes app's capability, default.json in the Code tab, grants its one window these permissions:
"windows": ["main"],
"permissions": [
"core:default",
"fs:default",
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$APPDATA/notes.json" }]
},
"dialog:default",
"notification:default"
]
main is that window's label, and core:default allows Tauri's own basic features. fs:default lets the page read inside the app's own folders and create them, nothing more. Writing needs its own permission, here scoped to $APPDATA/notes.json, the notes file in the app data folder.
In src/lib/notes.ts, saveNotes() writes that file, after mkdir() creates the folder, which is missing on the first run. exportNotes() writes a copy wherever the person chooses: a path they pick in the save dialog joins the scope until the app restarts.
Phones, updates and signing
The same project also runs on phones, and its desktop build can update itself.
Android needs Android Studio, Google's tool for Android apps, with its NDK (Native Development Kit), the JAVA_HOME, ANDROID_HOME and NDK_HOME environment variables, and Rust's Android targets, added with rustup target add.
With those in place, run npm run tauri android init once, which creates the Android project, src-tauri/gen/android. npm run tauri android dev then starts the app on an emulator (a phone simulated on your computer) or a connected phone. iOS works the same with npm run tauri ios init and npm run tauri ios dev, on a Mac with Xcode.
Files, dialogs and notifications work everywhere, but some plugins are desktop-only, such as global shortcuts and the updater, which installs new versions. npm run tauri add updater puts its crate under a desktop-only section of Cargo.toml, and its permission in a second capability file, capabilities/desktop.json, whose platforms list keeps it off phones.
The command still registers the plugin in lib.rs for every build, so phone builds fail to compile. Register it inside .setup(), which runs as the app starts, behind #[cfg(desktop)], a mark that keeps a line in desktop builds only:
.setup(|app| {
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
Ok(())
})
Phone builds now skip that line and compile.
The updater fetches release information from the endpoints addresses in tauri.conf.json and installs a new version only if its signature matches your public key, pubkey. That check cannot be turned off.
Make the key pair once with npm run tauri signer generate -- -w ~/.tauri/notes.key: the private key stays with you and signs each update, and the public key goes in pubkey. Build with the private key in the TAURI_SIGNING_PRIVATE_KEY environment variable, and the createUpdaterArtifacts setting writes signed update bundles next to the installers.
Your key proves updates come from you; the installer needs the operating system's own signature too. macOS will not open a downloaded app unless an Apple Developer account signs and notarises it (has Apple check it). Windows warns before an unsigned installer runs, and app stores accept only signed builds.
What goes wrong
Both mistakes are about permission: a capability's scope, and the person's own yes on a phone.
The first writes an export straight to the Documents folder, and fails even though the write permission is granted.
await writeTextFile('notes.json', JSON.stringify(notes), { baseDir: BaseDirectory.Document })
The promise rejects with forbidden path: /Users/asha/Documents/notes.json. The permission allows the command, but its scope allows only $APPDATA/notes.json. Let the person pick the place in the save dialog instead, as exportNotes() in the Code tab does:
const path = await save({ defaultPath: 'notes.json' })
if (!path) return // the person cancelled
await writeTextFile(path, JSON.stringify(notes, null, 2))
The picked path joins the scope, so the write succeeds.
The second mistake shows only on a phone: the export works, but its notification never appears, and there is no error to catch.
sendNotification({ title: 'Notes exported', body: path })
On desktop the plugin always reports permission as granted. Android 13 and later and iOS make the person allow notifications first, and sendNotification() returns nothing, so a refused call disappears. Ask before sending, as notify() in the Code tab does:
let granted = await isPermissionGranted()
if (!granted) granted = (await requestPermission()) === 'granted'
if (granted) sendNotification({ title, body })
The notification now goes out only after the person says yes.
What to remember
npm run tauri add <plugin>installs the Rust crate and the npm package, registers the plugin and adds its default permission.- A capability grants permissions to windows by label, and a scope limits the paths they reach.
fs:defaultonly reads and creates the app's own folders; a path outside the scope fails withforbidden path. tauri android initandtauri android devrun the same app on a phone, where notifications need the person's yes first. Desktop-only plugins go behind#[cfg(desktop)]and in a capability with aplatformslist.- Updates are verified against your own signing key; macOS notarisation and Windows code signing are separate signatures on the installer.
In your project
Do these in a real project. Each one says how to confirm it worked.- Commit a Tauri project to git (a new one from
npm create tauri-app@latestwill do), then runnpm run tauri add fsandgit diff. Besides the lock files,Cargo.tomlandpackage.jsonlist the plugin,lib.rshas.plugin(tauri_plugin_fs::init()), and the capability file hasfs:default. - Add
dialogandnotificationtoo, copysrc/lib/notes.tsfrom the Code tab, and log whatloadNotes()returns inonMounted()inApp.vue. Removefs:defaultfromcapabilities/default.json, restartnpm run tauri devand open the window's console (right-click, Inspect): it shows an error that startsfs.exists not allowed. Put it back and the console shows an empty list. - Run
npm run tauri android initandnpm run tauri android dev, and pick an emulator. The same Vue page opens on the emulator, running as a phone app.