Tauri 2 + React : Construire une application desktop multiplateforme avec Rust

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

Introduction

Electron a longtemps été le choix par défaut pour construire des applications desktop avec les technologies web, mais il embarque un navigateur Chromium complet avec chaque application — ce qui produit des binaires de 150 Mo+ et une consommation mémoire élevée. Tauri 2 change la donne en utilisant la webview native du système d'exploitation, produisant des applications 10 à 100 fois plus petites et nettement plus rapides.

Avec Tauri 2, vous écrivez votre interface en React (ou tout autre framework frontend) et votre logique backend en Rust. Le résultat est une application desktop sécurisée et performante qui fonctionne sur Windows, macOS, Linux — et même iOS et Android.

Dans ce tutoriel, vous allez construire une application de notes de bureau — une application de prise de notes simple mais fonctionnelle avec accès au système de fichiers, une icône dans le system tray et un packaging multiplateforme.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 18+ installé
  • Rust installé via rustup
  • Des connaissances de base en React et TypeScript
  • Un éditeur de code (VS Code recommandé avec l'extension rust-analyzer)

Exigences spécifiques par plateforme

macOS :

xcode-select --install

Windows :

  • Outils de build Microsoft Visual Studio C++
  • WebView2 (préinstallé sur Windows 10/11)

Linux (Ubuntu/Debian) :

sudo apt update
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev

Ce que vous allez construire

Une application de notes de bureau avec les fonctionnalités suivantes :

  • Créer, modifier et supprimer des notes
  • Sauvegarder les notes dans le système de fichiers local
  • Intégration du system tray avec des actions rapides
  • Packaging multiplateforme (Windows, macOS, Linux)
  • Contrôles de fenêtre et menus natifs

Étape 1 : Créer le projet Tauri

Utilisez l'outil officiel create-tauri-app pour initialiser votre projet :

npm create tauri-app@latest notes-app -- --template react-ts

Lorsque vous y êtes invité, sélectionnez :

  • Gestionnaire de paquets : npm
  • Template UI : React
  • Variante UI : TypeScript

Naviguez vers votre projet et installez les dépendances :

cd notes-app
npm install

Structure du projet

Votre projet comporte deux parties principales :

notes-app/
├── src/              # Frontend React
│   ├── App.tsx
│   ├── main.tsx
│   └── styles.css
├── src-tauri/        # Backend Rust
│   ├── src/
│   │   └── lib.rs
│   ├── Cargo.toml
│   ├── tauri.conf.json
│   └── capabilities/
└── package.json

Le répertoire src/ contient votre application React, tandis que src-tauri/ contient le backend Rust et la configuration Tauri.

Étape 2 : Configurer Tauri

Ouvrez src-tauri/tauri.conf.json et mettez à jour la configuration de l'application :

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "Notes App",
  "version": "1.0.0",
  "identifier": "com.notes.app",
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:1420",
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "npm run build"
  },
  "app": {
    "title": "Notes App",
    "windows": [
      {
        "title": "Notes App",
        "width": 900,
        "height": 680,
        "resizable": true,
        "fullscreen": false
      }
    ],
    "security": {
      "csp": null
    }
  },
  "bundle": {
    "active": true,
    "targets": "all",
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ]
  }
}

Étape 3 : Ajouter les plugins Tauri

Tauri 2 utilise un système de plugins pour accéder aux API système. Installez les plugins de système de fichiers et de dialogue :

npm run tauri add fs
npm run tauri add dialog

Cela met à jour à la fois le package.json (bindings JavaScript) et le Cargo.toml (dépendances Rust).

Configurer les permissions

Tauri 2 exige des permissions explicites pour l'accès aux plugins. Mettez à jour src-tauri/capabilities/default.json :

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    "fs:allow-read-text-file",
    "fs:allow-write-text-file",
    "fs:allow-exists",
    "fs:allow-mkdir",
    "fs:allow-read-dir",
    "fs:allow-remove",
    "fs:scope-app-data",
    "dialog:allow-open",
    "dialog:allow-save",
    "dialog:allow-message"
  ]
}

Étape 4 : Créer les commandes Rust du backend

Les commandes Tauri sont des fonctions Rust que votre frontend peut appeler. Ouvrez src-tauri/src/lib.rs et ajoutez la logique de gestion des notes :

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
 
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Note {
    pub id: String,
    pub title: String,
    pub content: String,
    pub created_at: String,
    pub updated_at: String,
}
 
fn get_notes_dir(app: &tauri::AppHandle) -> PathBuf {
    let data_dir = app.path().app_data_dir().expect("Failed to get app data dir");
    let notes_dir = data_dir.join("notes");
    if !notes_dir.exists() {
        fs::create_dir_all(&notes_dir).expect("Failed to create notes directory");
    }
    notes_dir
}
 
#[tauri::command]
fn list_notes(app: tauri::AppHandle) -> Result<Vec<Note>, String> {
    let notes_dir = get_notes_dir(&app);
    let mut notes: Vec<Note> = Vec::new();
 
    let entries = fs::read_dir(&notes_dir).map_err(|e| e.to_string())?;
 
    for entry in entries {
        let entry = entry.map_err(|e| e.to_string())?;
        let path = entry.path();
        if path.extension().map_or(false, |ext| ext == "json") {
            let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
            let note: Note = serde_json::from_str(&content).map_err(|e| e.to_string())?;
            notes.push(note);
        }
    }
 
    notes.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
    Ok(notes)
}
 
#[tauri::command]
fn save_note(app: tauri::AppHandle, note: Note) -> Result<Note, String> {
    let notes_dir = get_notes_dir(&app);
    let file_path = notes_dir.join(format!("{}.json", note.id));
    let json = serde_json::to_string_pretty(&note).map_err(|e| e.to_string())?;
    fs::write(&file_path, json).map_err(|e| e.to_string())?;
    Ok(note)
}
 
#[tauri::command]
fn delete_note(app: tauri::AppHandle, id: String) -> Result<(), String> {
    let notes_dir = get_notes_dir(&app);
    let file_path = notes_dir.join(format!("{}.json", id));
    if file_path.exists() {
        fs::remove_file(&file_path).map_err(|e| e.to_string())?;
    }
    Ok(())
}
 
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_dialog::init())
        .invoke_handler(tauri::generate_handler![list_notes, save_note, delete_note])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Ajoutez la dépendance serde_json dans src-tauri/Cargo.toml :

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"

Étape 5 : Construire le frontend React

Maintenant, créons l'interface utilisateur. D'abord, installez quelques paquets utiles :

npm install uuid
npm install -D @types/uuid

Définition du type Note

Créez src/types.ts :

export interface Note {
  id: string;
  title: string;
  content: string;
  created_at: string;
  updated_at: string;
}

Composant Liste des notes

Créez src/components/NotesList.tsx :

import { Note } from "../types";
 
interface NotesListProps {
  notes: Note[];
  selectedId: string | null;
  onSelect: (note: Note) => void;
  onDelete: (id: string) => void;
  onCreate: () => void;
}
 
export function NotesList({ notes, selectedId, onSelect, onDelete, onCreate }: NotesListProps) {
  return (
    <aside className="notes-sidebar">
      <div className="sidebar-header">
        <h2>Notes</h2>
        <button onClick={onCreate} className="btn-new" title="Nouvelle note">
          +
        </button>
      </div>
      <ul className="notes-list">
        {notes.map((note) => (
          <li
            key={note.id}
            className={`note-item ${selectedId === note.id ? "active" : ""}`}
            onClick={() => onSelect(note)}
          >
            <div className="note-item-title">
              {note.title || "Sans titre"}
            </div>
            <div className="note-item-date">
              {new Date(note.updated_at).toLocaleDateString("fr")}
            </div>
            <button
              className="btn-delete"
              onClick={(e) => {
                e.stopPropagation();
                onDelete(note.id);
              }}
              title="Supprimer"
            >
              ×
            </button>
          </li>
        ))}
      </ul>
    </aside>
  );
}

Composant Éditeur de notes

Créez src/components/NoteEditor.tsx :

import { useState, useEffect } from "react";
import { Note } from "../types";
 
interface NoteEditorProps {
  note: Note | null;
  onSave: (note: Note) => void;
}
 
export function NoteEditor({ note, onSave }: NoteEditorProps) {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
 
  useEffect(() => {
    if (note) {
      setTitle(note.title);
      setContent(note.content);
    }
  }, [note?.id]);
 
  if (!note) {
    return (
      <main className="editor-empty">
        <p>Sélectionnez une note ou créez-en une nouvelle</p>
      </main>
    );
  }
 
  const handleSave = () => {
    onSave({
      ...note,
      title,
      content,
      updated_at: new Date().toISOString(),
    });
  };
 
  return (
    <main className="editor">
      <input
        type="text"
        className="editor-title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        onBlur={handleSave}
        placeholder="Titre de la note..."
      />
      <textarea
        className="editor-content"
        value={content}
        onChange={(e) => setContent(e.target.value)}
        onBlur={handleSave}
        placeholder="Commencez à écrire..."
      />
    </main>
  );
}

Composant principal de l'application

Remplacez src/App.tsx :

import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { v4 as uuidv4 } from "uuid";
import { NotesList } from "./components/NotesList";
import { NoteEditor } from "./components/NoteEditor";
import { Note } from "./types";
import "./App.css";
 
function App() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [selectedNote, setSelectedNote] = useState<Note | null>(null);
 
  useEffect(() => {
    loadNotes();
  }, []);
 
  async function loadNotes() {
    try {
      const result = await invoke<Note[]>("list_notes");
      setNotes(result);
    } catch (error) {
      console.error("Failed to load notes:", error);
    }
  }
 
  async function createNote() {
    const newNote: Note = {
      id: uuidv4(),
      title: "",
      content: "",
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    };
 
    try {
      await invoke("save_note", { note: newNote });
      setNotes((prev) => [newNote, ...prev]);
      setSelectedNote(newNote);
    } catch (error) {
      console.error("Failed to create note:", error);
    }
  }
 
  async function saveNote(note: Note) {
    try {
      await invoke("save_note", { note });
      setNotes((prev) =>
        prev.map((n) => (n.id === note.id ? note : n))
      );
      setSelectedNote(note);
    } catch (error) {
      console.error("Failed to save note:", error);
    }
  }
 
  async function deleteNote(id: string) {
    try {
      await invoke("delete_note", { id });
      setNotes((prev) => prev.filter((n) => n.id !== id));
      if (selectedNote?.id === id) {
        setSelectedNote(null);
      }
    } catch (error) {
      console.error("Failed to delete note:", error);
    }
  }
 
  return (
    <div className="app">
      <NotesList
        notes={notes}
        selectedId={selectedNote?.id ?? null}
        onSelect={setSelectedNote}
        onDelete={deleteNote}
        onCreate={createNote}
      />
      <NoteEditor note={selectedNote} onSave={saveNote} />
    </div>
  );
}
 
export default App;

Styles

Remplacez src/App.css avec un design propre et natif :

:root {
  --sidebar-width: 280px;
  --bg-primary: #1e1e2e;
  --bg-secondary: #181825;
  --bg-surface: #313244;
  --text-primary: #cdd6f4;
  --text-secondary: #a6adc8;
  --accent: #89b4fa;
  --danger: #f38ba8;
  --border: #45475a;
}
 
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
 
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: var(--bg-primary);
  color: var(--text-primary);
}
 
.app {
  display: flex;
  height: 100vh;
}
 
/* Barre latérale */
.notes-sidebar {
  width: var(--sidebar-width);
  background: var(--bg-secondary);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
}
 
.sidebar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  border-bottom: 1px solid var(--border);
}
 
.sidebar-header h2 {
  font-size: 18px;
  font-weight: 600;
}
 
.btn-new {
  width: 32px;
  height: 32px;
  border-radius: 8px;
  border: none;
  background: var(--accent);
  color: var(--bg-primary);
  font-size: 20px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
 
.btn-new:hover {
  opacity: 0.85;
}
 
/* Liste des notes */
.notes-list {
  list-style: none;
  overflow-y: auto;
  flex: 1;
}
 
.note-item {
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  position: relative;
  transition: background 0.15s;
}
 
.note-item:hover {
  background: var(--bg-surface);
}
 
.note-item.active {
  background: var(--bg-surface);
  border-left: 3px solid var(--accent);
}
 
.note-item-title {
  font-size: 14px;
  font-weight: 500;
  margin-bottom: 4px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
 
.note-item-date {
  font-size: 11px;
  color: var(--text-secondary);
}
 
.btn-delete {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 24px;
  height: 24px;
  border-radius: 4px;
  border: none;
  background: transparent;
  color: var(--text-secondary);
  font-size: 16px;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s;
}
 
.note-item:hover .btn-delete {
  opacity: 1;
}
 
.btn-delete:hover {
  background: var(--danger);
  color: white;
}
 
/* Éditeur */
.editor {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: 24px;
}
 
.editor-empty {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-secondary);
}
 
.editor-title {
  font-size: 28px;
  font-weight: 700;
  border: none;
  background: transparent;
  color: var(--text-primary);
  outline: none;
  margin-bottom: 16px;
  padding: 4px 0;
}
 
.editor-content {
  flex: 1;
  border: none;
  background: transparent;
  color: var(--text-primary);
  outline: none;
  font-size: 15px;
  line-height: 1.7;
  resize: none;
  font-family: inherit;
}

Étape 6 : Lancer le serveur de développement

Démarrez le serveur de développement Tauri :

npm run tauri dev

Cela lance à la fois le serveur Vite (pour le rechargement à chaud de React) et la fenêtre Tauri. Vous devriez voir votre application de notes avec une barre latérale et un éditeur. Rust se recompile automatiquement lorsque vous modifiez les fichiers dans src-tauri/.

Étape 7 : Ajouter le system tray

Tauri 2 prend en charge nativement l'intégration du system tray. Mettez à jour src-tauri/src/lib.rs pour ajouter une icône dans la barre :

use tauri::{
    menu::{Menu, MenuItem},
    tray::TrayIconBuilder,
    Manager,
};
 
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_dialog::init())
        .setup(|app| {
            let quit = MenuItem::with_id(app, "quit", "Quitter", true, None::<&str>)?;
            let show = MenuItem::with_id(app, "show", "Afficher la fenêtre", true, None::<&str>)?;
            let menu = Menu::with_items(app, &[&show, &quit])?;
 
            TrayIconBuilder::new()
                .menu(&menu)
                .tooltip("Application Notes")
                .on_menu_event(|app, event| match event.id.as_ref() {
                    "quit" => app.exit(0),
                    "show" => {
                        if let Some(window) = app.get_webview_window("main") {
                            let _ = window.show();
                            let _ = window.set_focus();
                        }
                    }
                    _ => {}
                })
                .build(app)?;
 
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![list_notes, save_note, delete_note])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Ajoutez une image d'icône pour la barre dans src-tauri/icons/tray.png (PNG 32x32 avec transparence).

Étape 8 : Compiler et empaqueter

Lorsque votre application est prête, compilez-la pour la distribution :

npm run tauri build

Cela compile le backend Rust en mode release et empaquète votre application :

PlateformeSortieTaille
macOS.dmg, .app~8 Mo
Windows.msi, .exe~5 Mo
Linux.deb, .AppImage~7 Mo

Les bundles sont générés dans src-tauri/target/release/bundle/.

Comparez cela à une application Electron équivalente de 150-200 Mo — les applications Tauri sont considérablement plus petites.

Étape 9 : Mise à jour automatique (optionnel)

Tauri 2 inclut un système de mise à jour intégré. Ajoutez-le à votre projet :

npm run tauri add updater

Configurez le point de terminaison de mise à jour dans tauri.conf.json :

{
  "plugins": {
    "updater": {
      "endpoints": [
        "https://releases.myapp.com/{{target}}/{{arch}}/{{current_version}}"
      ],
      "pubkey": "YOUR_PUBLIC_KEY"
    }
  }
}

Générez les clés de signature avec :

npm run tauri signer generate -- -w ~/.tauri/myapp.key

Dépannage

Erreurs de compilation Rust

Assurez-vous que votre chaîne d'outils Rust est à jour :

rustup update

Fenêtre blanche sous Linux

Installez la bonne version de WebKitGTK :

sudo apt install libwebkit2gtk-4.1-dev

Erreurs de permission refusée

Vérifiez que votre capabilities/default.json inclut les permissions requises pour chaque plugin que vous utilisez. Tauri 2 est strict sur les permissions par conception.

Première compilation lente

La première compilation Rust télécharge et compile toutes les dépendances. Les compilations suivantes sont beaucoup plus rapides grâce au cache. Utilisez cargo clean uniquement lorsque c'est nécessaire.

Prochaines étapes

Maintenant que vous avez une application Tauri 2 fonctionnelle, vous pouvez :

  • Ajouter des raccourcis clavier avec le plugin de raccourcis globaux de Tauri
  • Implémenter le rendu Markdown dans l'éditeur
  • Ajouter une fonctionnalité de recherche dans les notes
  • Mettre en place un CI/CD avec GitHub Actions pour des compilations automatisées
  • Ajouter un stockage en base de données avec SQLite via le plugin tauri-plugin-sql
  • Compiler pour le mobile (iOS/Android) grâce au support mobile de Tauri 2

Conclusion

Vous avez construit une application desktop complète avec Tauri 2 et React. Comparée à Electron, votre application est plus petite, plus rapide et plus sécurisée — tirant parti des performances de Rust et du modèle de sécurité basé sur les permissions de Tauri. Le système de plugins facilite l'accès aux API natives tout en maintenant une empreinte minimale. Tauri 2 représente l'avenir du développement d'applications desktop multiplateformes pour les développeurs web.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Construire une application web full-stack avec SvelteKit 2 : guide pratique complet.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes