Skip to main content

Tauri Desktop App Development in Cursor: Rust Backend, Commands, and Security

Tauri Desktop App Development in Cursor

Tauri is a framework for building tiny, fast, and secure desktop applications by combining a Rust backend with a web frontend of your choice. Unlike Electron, Tauri uses the operating system's native webview instead of bundling Chromium, resulting in dramatically smaller binaries and lower memory usage. Cursor, an AI-powered IDE editor, accelerates Tauri development by scaffolding projects, generating Rust commands, writing type-safe TypeScript bindings, and auditing your security configuration. This guide covers the Tauri architecture, Rust commands, the plugin system, the security model, and AI-assisted desktop integration.

Prerequisites​

Before you begin, make sure you have the following:

  • Cursor (latest version recommended) as your IDE editor
  • Rust toolchain (install via rustup) with the stable compiler
  • Node.js 18 or higher for the frontend tooling
  • System dependencies: on Linux, install webkit2gtk, build-essential, and related packages; on macOS, install Xcode Command Line Tools; on Windows, install the Visual Studio C++ Build Tools
tip

If you have never written Rust before, ask Cursor to explain ownership, borrowing, and lifetimes using examples from Tauri command handlers. Understanding these concepts early prevents compiler errors that can feel cryptic to JavaScript developers transitioning to Rust.

Understanding the Tauri Architecture​

A Tauri application has three layers:

  • Frontend: Your web application (React, Vue, Svelte, or plain HTML). This runs in the OS-native webview.
  • Core (Rust): The Tauri runtime, written in Rust, manages windows, the application lifecycle, and native system calls.
  • Commands: Custom Rust functions that the frontend calls through Tauri's IPC layer. This is how your web UI interacts with the filesystem, databases, and OS APIs.

The key difference from Electron is that the Rust core has full system access while the frontend is sandboxed in the webview. Communication flows exclusively through explicitly declared commands and events.

Project Scaffolding in Cursor​

Ask Cursor to scaffold a Tauri + React + TypeScript project:

Create a Tauri 2.0 project with React and TypeScript.
Set up the frontend with Vite, configure the Rust backend,
and include the tauri-plugin-fs and tauri-plugin-dialog plugins.

Cursor generates the standard Tauri project structure:

my-tauri-app/
src/ # Frontend source
App.tsx
main.tsx
src-tauri/ # Rust backend
src/
main.rs
lib.rs
Cargo.toml
tauri.conf.json
capabilities/
default.json
package.json
vite.config.ts
warning

Tauri 2.0 introduced significant changes from Tauri 1.x, including a new permissions system, capabilities files, and a redesigned plugin API. If you are following older tutorials, verify that the API calls match Tauri 2.0 syntax. Ask Cursor to migrate any Tauri 1.x code you encounter.

Rust Backend and Commands​

Commands are the bridge between your Rust backend and your web frontend. You define a Rust function, annotate it with #[tauri::command], and invoke it from JavaScript.

Defining Commands​

// src-tauri/src/lib.rs
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{Manager, State};

// A shared application state managed by Tauri
pub struct AppState {
pub config_dir: PathBuf,
}

#[derive(Serialize, Deserialize)]
pub struct FileEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub size: u64,
}

/// List files in a directory
#[tauri::command]
pub async fn list_files(dir: String) -> Result<Vec<FileEntry>, String> {
let entries = fs::read_dir(&dir).map_err(|e| e.to_string())?;

let mut files: Vec<FileEntry> = entries
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let metadata = entry.metadata().ok()?;
Ok(FileEntry {
name: entry.file_name().to_string_lossy().to_string(),
path: entry.path().to_string_lossy().to_string(),
is_dir: metadata.is_dir(),
size: metadata.len(),
})
})
.collect();

files.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
Ok(files)
}

/// Save content to a file
#[tauri::command]
pub async fn save_file(path: String, content: String) -> Result<(), String> {
fs::write(&path, content).map_err(|e| e.to_string())
}

/// Read a file's content
#[tauri::command]
pub async fn read_file(path: String) -> Result<String, String> {
fs::read_to_string(&path).map_err(|e| e.to_string())
}

/// Get the app data directory
#[tauri::command]
pub fn get_data_dir(state: State<AppState>) -> String {
state.config_dir.to_string_lossy().to_string()
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.manage(AppState {
config_dir: tauri::api::path::app_config_dir(
&tauri::generate_context!(),
).unwrap_or_else(|_| PathBuf::from(".")),
})
.invoke_handler(tauri::generate_handler![
list_files,
save_file,
read_file,
get_data_dir,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

Calling Commands from the Frontend​

// src/lib/tauri-api.ts
import { invoke } from '@tauri-apps/api/core';

export interface FileEntry {
name: string;
path: string;
is_dir: boolean;
size: number;
}

export async function listFiles(dir: string): Promise<FileEntry[]> {
return invoke<FileEntry[]>('list_files', { dir });
}

export async function saveFile(path: string, content: string): Promise<void> {
await invoke('save_file', { path, content });
}

export async function readFile(path: string): Promise<string> {
return invoke<string>('read_file', { path });
}

export async function getDataDir(): Promise<string> {
return invoke<string>('get_data_dir');
}
tip

Ask Cursor to generate TypeScript interfaces that mirror your Rust structs. A prompt like "Create TypeScript types for every struct in my Tauri command handlers and put them in a shared types file" ensures the frontend and backend stay in sync, catching type mismatches at compile time.

warning

Rust's Result type maps directly to JavaScript Promise rejection. If your command returns Result<T, String>, the frontend receives a rejected promise with the error string. Always handle errors with .catch() or try/catch in the frontend to avoid unhandled promise rejections.

The Plugin System​

Tauri 2.0 uses a modular plugin architecture. Core functionality like filesystem access, dialogs, shell commands, and HTTP requests are provided by official plugins. You can also create custom plugins to share functionality across apps.

Installing Official Plugins​

# File system access
npm install @tauri-apps/plugin-fs
cargo add tauri-plugin-fs --manifest-path src-tauri/Cargo.toml

# Native dialogs (open, save)
npm install @tauri-apps/plugin-dialog
cargo add tauri-plugin-dialog --manifest-path src-tauri/Cargo.toml

# Shell commands (open URLs, run scripts)
npm install @tauri-apps/plugin-shell
cargo add tauri-plugin-shell --manifest-path src-tauri/Cargo.toml

# HTTP client
npm install @tauri-apps/plugin-http
cargo add tauri-plugin-http --manifest-path src-tauri/Cargo.toml

Each plugin must be registered in the Tauri builder and granted permissions through a capabilities file.

Using the Dialog Plugin​

// src/components/FileBrowser.tsx
import { open, save } from '@tauri-apps/plugin-dialog';
import { readFile, writeFile } from '@tauri-apps/plugin-fs';
import { useState } from 'react';

export function FileBrowser() {
const [content, setContent] = useState('');
const [filePath, setFilePath] = useState<string | null>(null);

async function handleOpen() {
const selected = await open({
multiple: false,
filters: [{ name: 'Markdown', extensions: ['md'] }],
});

if (typeof selected === 'string') {
setFilePath(selected);
const bytes = await readFile(selected);
setContent(new TextDecoder().decode(bytes));
}
}

async function handleSave() {
if (!filePath) return;
await writeFile(filePath, new TextEncoder().encode(content));
}

async function handleSaveAs() {
const target = await save({
filters: [{ name: 'Markdown', extensions: ['md'] }],
});
if (target) {
setFilePath(target);
await writeFile(target, new TextEncoder().encode(content));
}
}

return (
<div className="flex flex-col h-full">
<div className="flex gap-2 p-2">
<button onClick={handleOpen} className="rounded px-3 py-1 bg-blue-600 text-white">
Open
</button>
<button onClick={handleSave} className="rounded px-3 py-1 bg-green-600 text-white">
Save
</button>
<button onClick={handleSaveAs} className="rounded px-3 py-1 bg-gray-600 text-white">
Save As
</button>
</div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="flex-1 p-4 font-mono text-sm"
placeholder="Select a file or start typing..."
/>
</div>
);
}
tip

Ask Cursor to scaffold a custom Tauri plugin. A prompt like "Create a Tauri plugin that exposes a Rust function to read system environment variables and register it in my app" demonstrates how to structure plugin code, register permissions, and expose the API to the frontend.

The Security Model​

Tauri 2.0 introduced a fine-grained permissions system based on capabilities. Every command, plugin API, and resource access point must be explicitly allowed in a capabilities file.

Capabilities Configuration​

// src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"fs:allow-read-text-file",
"fs:allow-write-file",
"fs:allow-create-dir",
"dialog:allow-open",
"dialog:allow-save",
"shell:allow-open",
"http:default"
]
}

Scoping Filesystem Access​

You can restrict which directories a plugin can access using scope rules:

{
"identifier": "default",
"windows": ["main"],
"permissions": [
{
"identifier": "fs:allow-read-file",
"allow": [
{ "path": "$APPDATA/**" },
{ "path": "$DOCUMENT/**" }
],
"deny": [
{ "path": "$DOCUMENT/.ssh/**" }
]
}
]
}
warning

Never use fs:default or fs:allow-read-file without a scope. These broad permissions grant access to the entire filesystem from the webview, which defeats the purpose of Tauri's sandboxed architecture. Always scope access to specific directories like $APPDATA or user-selected paths.

tip

Run this prompt in Cursor to audit your Tauri security configuration: "Review my capabilities files and Cargo.toml for overly broad permissions. Flag any permission that grants filesystem, shell, or HTTP access without an explicit scope."

Events and Inter-Process Communication​

In addition to commands, Tauri supports an event system for asynchronous communication from the Rust backend to the frontend.

Emitting Events from Rust​

use tauri::{Emitter, Window};
use std::time::Duration;
use tokio::time::interval;

/// Background task that emits progress updates
#[tauri::command]
pub async fn start_processing(window: Window) -> Result<(), String> {
let mut ticker = interval(Duration::from_millis(500));

for step in 0..=100 {
ticker.tick().await;

window
.emit("processing-progress", serde_json::json!({
"step": step,
"total": 100,
"message": format!("Processing item {} of 100", step),
}))
.map_err(|e| e.to_string())?;

if step == 50 {
window
.emit("processing-warning", "Halfway through, memory is high")
.map_err(|e| e.to_string())?;
}
}

window
.emit("processing-complete", "All items processed successfully")
.map_err(|e| e.to_string())?;

Ok(())
}

Listening to Events in the Frontend​

// src/components/ProcessingView.tsx
import { listen } from '@tauri-apps/api/event';
import { useEffect, useState } from 'react';

interface ProgressEvent {
step: number;
total: number;
message: string;
}

export function ProcessingView() {
const [progress, setProgress] = useState<ProgressEvent | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const [done, setDone] = useState(false);

useEffect(() => {
const unlisteners: Array<() => void> = [];

const setup = async () => {
unlisteners.push(
await listen<ProgressEvent>('processing-progress', (event) => {
setProgress(event.payload);
})
);

unlisteners.push(
await listen<string>('processing-warning', (event) => {
setWarning(event.payload);
})
);

unlisteners.push(
await listen<string>('processing-complete', (event) => {
setDone(true);
})
);
};

setup();

return () => {
unlisteners.forEach((unlisten) => unlisten());
};
}, []);

return (
<div className="p-4">
{progress && (
<>
<div className="mb-2 text-sm text-gray-600">{progress.message}</div>
<div className="h-3 rounded-full bg-gray-200">
<div
className="h-3 rounded-full bg-blue-600 transition-all"
style={{ width: `${(progress.step / progress.total) * 100}%` }}
/>
</div>
</>
)}
{warning && (
<div className="mt-2 rounded bg-yellow-100 p-2 text-sm text-yellow-800">
{warning}
</div>
)}
{done && (
<div className="mt-4 rounded bg-green-100 p-2 text-sm text-green-800">
Processing complete!
</div>
)}
</div>
);
}
tip

Always clean up event listeners in your React useEffect cleanup function. Tauri event listeners persist across component remounts. Failing to unsubscribe causes duplicate handlers that fire multiple times and create memory leaks.

AI-Assisted Desktop Integration​

Cursor can generate complete desktop integration features by describing the desired behavior. Here are practical examples.

System Tray Integration​

Create a Tauri system tray icon with a context menu containing
"Show Window", "Settings", and "Quit". The tray icon should show
a notification badge when there are unread items. Write the Rust
code in src-tauri/src/tray.rs and register it in the builder.

Cursor generates:

// src-tauri/src/tray.rs
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{TrayIconBuilder, TrayIconEvent, MouseButton, MouseButtonState},
Manager, Emitter,
};

pub fn setup_tray(app: &tauri::App) -> tauri::Result<()> {
let show = MenuItem::with_id(app, "show", "Show Window", true, None)?;
let settings = MenuItem::with_id(app, "settings", "Settings", true, None)?;
let separator = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None)?;

let menu = Menu::with_items(app, &[&show, &settings, &separator, &quit])?;

TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.tooltip("My Tauri App")
.menu(&menu)
.on_menu_event(|app, event| {
match event.id().as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"settings" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit("navigate", "/settings");
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;

Ok(())
}
warning

System tray icons behave differently across platforms. On macOS, clicking the tray icon should toggle window visibility. On Windows and Linux, right-click opens the context menu. Ask Cursor to generate platform-specific handlers and test on all target platforms before releasing.

Building and Distributing​

Tauri builds native binaries for each platform with no external runtime dependencies.

Build Commands​

# Build for the current platform
npm run tauri build

# Build with a specific bundle type
npm run tauri build -- --bundles dmg # macOS DMG
npm run tauri build -- --bundles nsis # Windows NSIS installer
npm run tauri build -- --bundles deb # Linux DEB

# Debug build with devtools
npm run tauri build -- --debug

Configuration​

// src-tauri/tauri.conf.json
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "My Tauri App",
"version": "1.0.0",
"identifier": "com.example.my-tauri-app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "My Tauri App",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"center": true,
"decorations": true
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'",
"freezePrototype": true
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
tip

Ask Cursor to generate a GitHub Actions CI workflow that builds your Tauri app for macOS, Windows, and Ubuntu. A prompt like "Create a CI workflow that installs Rust, Node, and system dependencies for Tauri, then runs tauri build on three platforms and uploads the bundles as release assets" gives you a production-ready pipeline.

Summary​

Tauri in Cursor gives you the best of both worlds: a tiny, secure Rust core with a familiar web frontend. By following this guide's patterns, typed Rust commands, scoped plugin permissions, capability-based security, event-driven communication, and platform-aware desktop integration, you can ship lightweight desktop applications that are an order of magnitude smaller than Electron equivalents. Keep your capabilities files scoped tightly, let Cursor generate and audit your Rust commands, and always test platform-specific features on every target OS.