Si tu as lu l'introduction à Tauri, tu sais ce qu'est Tauri et comment ça fonctionne : une application de bureau native où l'UI est en technologie web et un backend en Rust gère le côté système d'exploitation via IPC. La question suivante qui revient souvent est : comment écrire une base de code qui tourne à la fois là-bas et dans un navigateur normal ?
Cette question arrive tôt. Tes composants React ont besoin de lire et écrire des fichiers, d'afficher des notifications et de parler au système d'exploitation. Dans Tauri, tout ça passe par invoke() et le backend Rust. Mais tu veux aussi faire tourner le même frontend dans un navigateur pendant le développement, ou le livrer comme une app web à côté de la version bureau. Dans le navigateur, invoke() ne fait rien. Il n'y a pas de Rust à l'autre bout.
L'approche naïve consiste à parsemer des vérifications isTauri() partout dans tes composants. Un composant appelle invoke() quand il est dans Tauri et fetch() quand il est dans le navigateur. Un autre fait pareil pour les notifications. Un troisième pour les sauvegardes de fichiers. Au bout d'un moment, chaque composant connaît les deux runtimes, et le code est plein de branches qui font des choses différentes selon l'endroit où l'app tourne.
Ça fonctionne, mais ça passe mal à l'échelle. Chaque nouveau composant qui a besoin d'une capacité native doit apprendre la même leçon. Chaque composant porte le poids de deux runtimes. Et au moment où tu veux ajouter un troisième runtime, disons un build mobile, tu recommences à éditer des composants.
Il y a une meilleure forme pour ça.
Mettre le runtime derrière une interface
Le schéma, c'est d'arrêter de laisser tes composants connaître le runtime. À la place, tu définis une interface, et tu échanges l'implémentation en fonction de l'endroit où l'app tourne.
Prends les opérations sur fichiers. Tes composants ont besoin de sauvegarder des documents, de lire des fichiers et de lister des répertoires. Définis à quoi ça ressemble sans te soucier de comment ça arrive :
interface FileService {
saveDocument(doc: Document): Promise<void>;
getFiles(): Promise<string[]>;
readFile(path: string): Promise<string>;
}Tes composants React dépendent de cette interface. Ils n'appellent jamais invoke(). Ils n'appellent jamais fetch(). Ils appellent fileService.saveDocument(doc) et passent à la suite.
function SaveButton({ doc }: { doc: Document }) {
const fileService = useFileService();
return (
<button onClick={() => fileService.saveDocument(doc)}>
Save
</button>
);
}Le composant ne sait pas si cette sauvegarde va vers Rust, vers une API HTTP, ou vers un mock dans un test. Il n'a pas besoin de le savoir.
Deux implémentations
Tu écris deux implémentations de cette interface. Une pour le navigateur, une pour Tauri.
La version navigateur parle à ton backend via HTTP :
class WebFileService implements FileService {
async saveDocument(doc: Document): Promise<void> {
await fetch("/api/documents", {
method: "POST",
body: JSON.stringify(doc),
});
}
async getFiles(): Promise<string[]> {
const res = await fetch("/api/files");
return res.json();
}
async readFile(path: string): Promise<string> {
const res = await fetch(`/api/files/${encodeURIComponent(path)}`);
return res.text();
}
}La version Tauri envoie des messages à Rust via invoke() :
class TauriFileService implements FileService {
async saveDocument(doc: Document): Promise<void> {
await invoke("save_document", { doc });
}
async getFiles(): Promise<string[]> {
return invoke<string[]>("get_files");
}
async readFile(path: string): Promise<string> {
return invoke<string>("read_file", { path });
}
}Même interface, deux mécanismes complètement différents en dessous. L'un passe par le réseau. L'autre passe par IPC vers une fonction Rust compilée qui écrit sur le vrai système de fichiers.
En choisir une à la frontière
Tu choisis l'implémentation une fois, à la frontière où ton app démarre, et tout ce qui est en aval reçoit la bonne :
function createFileService(): FileService {
if (isTauri()) {
return new TauriFileService();
}
return new WebFileService();
}Ou avec un provider, si tu préfères le contexte React :
const FileServiceContext = createContext<FileService>(
isTauri() ? new TauriFileService() : new WebFileService()
);
function useFileService(): FileService {
return useContext(FileServiceContext);
}Maintenant chaque composant de l'app reçoit la bonne implémentation sans jamais savoir laquelle c'est. La vérification isTauri() se fait à un seul endroit. Les composants sont propres.
Ce qui est partagé, ce qui est échangé
Ce schéma fonctionne parce que la majeure partie de ton application se fiche du runtime. L'UI, la gestion d'état, les règles métier, la validation, le routage, l'arbre des composants, tout ça est identique quel que soit l'endroit où l'app tourne.
| Partagé | Échangé |
|---|---|
| Composants | Accès aux fichiers |
| Gestion d'état | Notifications |
| Règles métier | Intégrations OS |
| Validation | Méthode d'authentification |
| Routage | Stockage |
| Arbre des composants | Couche réseau |
La colonne partagée, c'est la majeure partie de ta base de code. La colonne échangée, c'est la surface où ton app touche le monde extérieur, et cette surface, c'est là que le runtime compte vraiment. En mettant chaque pièce échangée derrière une interface, tu gardes les différences de runtime à un seul endroit au lieu de les répandre dans chaque composant.
Pourquoi c'est le bon schéma pour Tauri
Tauri ne te donne pas ça gratuitement. Si tu commences juste à appeler invoke() dans tes composants, tu te retrouves avec une app de bureau qui se trouve contenir du code web. Elle ne tournera plus dans un navigateur, parce que chaque composant dépend de la présence du pont Rust. Tu as verrouillé ton frontend au runtime bureau.
Le schéma d'interface inverse ça. Tu obtiens une app web qui peut devenir une app de bureau, parce que l'app web a été construite sans connaître le runtime bureau. Les capacités bureau ont été ajoutées aux frontières, et l'application centrale n'en a jamais dépendu.
Cette distinction compte pour plus que le simple fait de tourner dans un navigateur. Ça signifie que tu peux tester tes composants de façon isolée, parce qu'ils dépendent d'une interface, pas de invoke(). Ça signifie que tu peux ajouter un nouveau runtime sans toucher aux composants, en écrivant une nouvelle implémentation de l'interface. Ça signifie que la décision « est-ce une app web ou une app de bureau » est un choix de configuration, pas un choix structurel.
Un exemple réel
Dis que tu construis une application de prise de notes. Dans le navigateur, les notes sont sauvegardées vers ton API backend. Dans Tauri, les notes sont sauvegardées vers le système de fichiers local via Rust. L'UI est la même dans les deux cas : une liste de notes, un éditeur, un bouton de sauvegarde.
SaveButton
|
FileService (interface)
/ \
/ \
WebFileService TauriFileService
| |
HTTP POST /api/notes invoke("save_note")
| |
Serveur backend Fonction Rust
| |
Base de données Système de fichiers localLe SaveButton ne sait pas de quel côté il est. L'éditeur ne le sait pas. La NoteList ne le sait pas. La seule chose qui le sait, c'est la ligne de code qui choisit l'implémentation au démarrage de l'app.
Quand l'utilisateur clique sur sauvegarder dans le navigateur, la requête va à ton backend. Quand il clique sur sauvegarder dans l'app de bureau, la requête va à Rust, qui écrit sur le disque. Le code du composant est identique. L'expérience est différente. L'emplacement de stockage est différent. La limite de confiance est différente. Mais le code React avec lequel l'utilisateur interagit est le même.
Où tracer les lignes
Les interfaces que tu définis devraient correspondre aux besoins de ton application, pas aux capacités du runtime. Ne crée pas un TauriService qui encapsule chaque appel invoke(). Ça ne fait que déplacer le problème. Crée un FileService pour les opérations sur fichiers, un NotificationService pour les notifications, un AuthService pour l'authentification. Chaque interface décrit ce que ton application a besoin de faire, et chaque runtime fournit sa propre implémentation de ce besoin.
La question à se poser pour décider si quelque chose va derrière une interface est : est-ce que ça fonctionne différemment dans le navigateur que dans Tauri ? Si oui, ça va derrière une interface. Si non, c'est du code partagé et les deux runtimes utilisent la même implémentation.
L'accès aux fichiers est différent. Les notifications sont différentes. Les intégrations OS sont différentes. La validation de formulaire est la même. La gestion d'état est la même. Le routage est le même. La ligne est tracée là où le runtime diverge vraiment, et une fois tracée, la divergence vit à un seul endroit.
Lire la suite
Rencontrez Tauri [2.0]
Comment Tauri construit des applications de bureau à partir de technologies web, où il place la limite de confiance par rapport à Electron, et le changement de mentalité d'un site web dans une fenêtre vers une application native.
Architecturer les budgets d'AI Gateway : Virtual Keys, Provider Keys et quotas distribués
Un plan pour concevoir un système global de quotas d'IA. Comment séparer l'exécution des requêtes de la vérité du budget sous forte concurrence.
LiteLLM vs. Bifrost : Choisir un AI Gateway
Une comparaison pratique entre l'état distribué basé sur Python de LiteLLM et la performance brute basée sur Go de Bifrost, pour vous aider à choisir un AI gateway.
