Pular para o conteúdo principal

Desenvolvimento de Aplicativos Desktop Tauri no Cursor: Backend Rust, Comandos e Segurança

Desenvolvimento de Aplicativos Desktop Tauri no Cursor

Tauri é um framework para construir aplicativos desktop pequenos, rápidos e seguros combinando um backend Rust com um frontend web de sua escolha. Diferentemente do Electron, o Tauri usa a webview nativa do sistema operacional em vez de empacotar o Chromium, resultando em binários dramaticamente menores e menor uso de memória. Cursor, um editor IDE alimentado por IA, acelera o desenvolvimento Tauri fazendo scaffold de projetos, gerando comandos Rust, escrevendo bindings TypeScript type-safe e auditando sua configuração de segurança. Este guia aborda a arquitetura Tauri, comandos Rust, o sistema de plugins, o modelo de segurança e integração de desktop assistida por IA.

Pré-requisitos​

Antes de começar, certifique-se de que você tem o seguinte:

  • Cursor (versão mais recente recomendada) como seu editor IDE
  • Toolchain Rust (instale via rustup) com o compilador stable
  • Node.js 18 ou superior para o tooling de frontend
  • Dependências de sistema: no Linux, instale webkit2gtk, build-essential e pacotes relacionados; no macOS, instale o Xcode Command Line Tools; no Windows, instale o Visual Studio C++ Build Tools
dica

Se você nunca escreveu Rust antes, peça ao Cursor para explicar ownership, borrowing e lifetimes usando exemplos de handlers de comando do Tauri. Entender esses conceitos cedo evita erros de compilador que podem parecer crípticos para desenvolvedores JavaScript em transição para Rust.

Entendendo a Arquitetura Tauri​

Uma aplicação Tauri tem três camadas:

  • Frontend: Sua aplicação web (React, Vue, Svelte ou HTML puro). Roda na webview nativa do SO.
  • Core (Rust): O runtime Tauri, escrito em Rust, gerencia janelas, o ciclo de vida da aplicação e chamadas de sistema nativas.
  • Comandos: Funções Rust customizadas que o frontend chama através da camada IPC do Tauri. É assim que sua UI web interage com o sistema de arquivos, bancos de dados e APIs do SO.

A diferença chave do Electron é que o core Rust tem acesso total ao sistema enquanto o frontend é isolado (sandboxed) na webview. A comunicação flui exclusivamente através de comandos e eventos explicitamente declarados.

Scaffold de Projeto no Cursor​

Peça ao Cursor para fazer scaffold de um projeto Tauri + React + TypeScript:

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.

O Cursor gera a estrutura padrão de projeto Tauri:

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
aviso

O Tauri 2.0 introduziu mudanças significativas em relação ao Tauri 1.x, incluindo um novo sistema de permissões, arquivos de capabilities e uma API de plugin redesenhada. Se você está seguindo tutoriais mais antigos, verifique se as chamadas de API correspondem à sintaxe do Tauri 2.0. Peça ao Cursor para migrar qualquer código Tauri 1.x que você encontre.

Backend Rust e Comandos​

Comandos são a ponte entre seu backend Rust e seu frontend web. Você define uma função Rust, anota-a com #[tauri::command] e a invoca do JavaScript.

Definindo Comandos​

// 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");
}

Chamando Comandos do 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');
}
dica

Peça ao Cursor para gerar interfaces TypeScript que espelhem suas structs Rust. Um prompt como "Crie tipos TypeScript para cada struct nos meus handlers de comando Tauri e coloque-os em um arquivo de tipos compartilhado" garante que o frontend e o backend permaneçam sincronizados, detectando incompatibilidades de tipo em tempo de compilação.

aviso

O tipo Result do Rust mapeia diretamente para rejeição de Promise do JavaScript. Se seu comando retorna Result<T, String>, o frontend recebe uma promise rejeitada com a string de erro. Sempre trate erros com .catch() ou try/catch no frontend para evitar rejeições de promise não tratadas.

O Sistema de Plugins​

O Tauri 2.0 usa uma arquitetura de plugin modular. Funcionalidade principal como acesso ao sistema de arquivos, diálogos, comandos de shell e requisições HTTP são fornecidas por plugins oficiais. Você também pode criar plugins customizados para compartilhar funcionalidade entre apps.

Instalando Plugins Oficiais​

# 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

Cada plugin deve ser registrado no builder do Tauri e receber permissões através de um arquivo de capabilities.

// 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>
);
}
dica

Peça ao Cursor para fazer scaffold de um plugin Tauri customizado. Um prompt como "Crie um plugin Tauri que expõe uma função Rust para ler variáveis de ambiente do sistema e registre-o no meu app" demonstra como estruturar código de plugin, registrar permissões e expor a API ao frontend.

O Modelo de Segurança​

O Tauri 2.0 introduziu um sistema de permissões granular baseado em capabilities. Cada comando, API de plugin e ponto de acesso a recursos deve ser explicitamente permitido em um arquivo de capabilities.

Configuração de Capabilities​

// 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"
]
}

Restringindo Acesso ao Sistema de Arquivos​

Você pode restringir a quais diretórios um plugin pode acessar usando regras de scope:

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

Nunca use fs:default ou fs:allow-read-file sem um scope. Essas permissões amplas concedem acesso a todo o sistema de arquivos a partir da webview, o que derrota o propósito da arquitetura sandboxed do Tauri. Sempre restrinja o acesso a diretórios específicos como $APPDATA ou caminhos selecionados pelo usuário.

dica

Execute este prompt no Cursor para auditar sua configuração de segurança Tauri: "Revise meus arquivos de capabilities e Cargo.toml quanto a permissões excessivamente amplas. Sinalize qualquer permissão que conceda acesso ao sistema de arquivos, shell ou HTTP sem um scope explícito."

Eventos e Comunicação Inter-Processo​

Além de comandos, o Tauri suporta um sistema de eventos para comunicação assíncrona do backend Rust para o frontend.

Emitindo Eventos do 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(())
}

Escutando Eventos no 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>
);
}
dica

Sempre limpe event listeners na função de cleanup do seu useEffect do React. Listeners de evento do Tauri persistem através de remontagens de componente. Falhar em desinscrever causa handlers duplicados que disparam múltiplas vezes e criam vazamentos de memória.

Integração de Desktop Assistida por IA​

O Cursor pode gerar recursos completos de integração de desktop descrevendo o comportamento desejado. Aqui estão exemplos práticos.

Integração com System Tray​

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.

O Cursor gera:

// 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(())
}
aviso

Ícones de system tray se comportam de forma diferente em cada plataforma. No macOS, clicar no ícone do tray deve alternar a visibilidade da janela. No Windows e Linux, o botão direito abre o menu de contexto. Peça ao Cursor para gerar handlers específicos de plataforma e teste em todas as plataformas-alvo antes do release.

Build e Distribuição​

O Tauri constrói binários nativos para cada plataforma sem dependências de runtime externas.

Comandos de Build​

# 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

Configuração​

// 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"
]
}
}
dica

Peça ao Cursor para gerar um workflow de CI do GitHub Actions que constrói seu app Tauri para macOS, Windows e Ubuntu. Um prompt como "Crie um workflow de CI que instala Rust, Node e dependências de sistema para Tauri, depois executa tauri build em três plataformas e faz upload dos bundles como assets de release" fornece um pipeline pronto para produção.

Resumo​

Tauri no Cursor oferece o melhor dos dois mundos: um core Rust pequeno e seguro com um frontend web familiar. Seguindo os padrões deste guia — comandos Rust tipados, permissões de plugin com escopo, segurança baseada em capabilities, comunicação orientada a eventos e integração de desktop ciente de plataforma — você pode enviar aplicativos desktop leves que são uma ordem de magnitude menores que equivalentes em Electron. Mantenha seus arquivos de capabilities estritamente restritos, deixe o Cursor gerar e auditar seus comandos Rust e sempre teste recursos específicos de plataforma em cada SO-alvo.