17. Commandes Tauri, appeler Rust depuis l'interface
Sections
Réglages de lecture
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
Code et termes techniques en anglais
Désactivé : la voix de la page lit tout, sans pause entre deux voix.
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
D’autres voix : Microsoft Edge propose des voix naturelles en ligne, et Windows (Paramètres › Heure et langue › Voix), macOS et Android en installent d’autres.
Exemple complet : l16-tauri/src-tauri/src/commands.rs et ui/main.js ; les tests de src-tauri/tests/ appellent chaque commande à travers la couche IPC de Tauri (cargo test -p chord-explorer).
Ce que vous connaissez déjà
Section intitulée « Ce que vous connaissez déjà »Une commande est une fonction Rust que la page appelle par son nom, avec des arguments JSON, et dont le résultat revient sous forme de promesse JavaScript. Elle est plus proche d’une API web que d’une commande WPF :
| Tauri | WPF | ASP.NET Core / Spring | Electron | |
|---|---|---|---|---|
| Déclarée avec | #[tauri::command] |
ICommand sur le view model |
une action de contrôleur, @RestController |
ipcMain.handle |
| Enregistrée avec | generate_handler! |
un binding XAML | le routage | le nom du canal |
| Appelée avec | invoke |
un clic sur un bouton lié | fetch |
ipcRenderer.invoke derrière contextBridge |
| Format des données | JSON via serde | objets .NET, même processus | JSON | clonage structuré |
| Erreurs | une promesse rejetée qui porte votre erreur sérialisée | exceptions | code de statut + ProblemDetails |
une promesse rejetée avec une nouvelle Error |
La page et le code Rust vivent dans des processus différents (leçon 16) : il n’y a ni objet partagé ni référence, chaque argument est désérialisé à l’arrivée et chaque résultat sérialisé au départ.
Une première commande
Section intitulée « Une première commande »Extrait de src-tauri/src/commands.rs, lignes 95-99 :
#[tauri::command]pub fn spell_chord(symbol: &str) -> Result<ChordView, CommandError> { let chord: Chord = symbol.parse()?; Ok(chord.into())}L’attribut génère la plomberie : lire symbol dans le JSON de la requête, appeler la fonction, sérialiser le Result. La fonction elle-même reste une fonction Rust ordinaire, que vous pouvez appeler et tester directement.
Chaque commande est enregistrée une fois, dans un seul generate_handler! (appeler invoke_handler deux fois ne garde que la dernière liste). L’application du cours le fait dans une fonction setup que les tests réutilisent (extrait de src-tauri/src/lib.rs, lignes 17-33) :
pub fn setup<R: Runtime>(builder: Builder<R>) -> Builder<R> { builder .manage(state::Favorites::default()) .manage(state::SearchControl::default()) .invoke_handler(tauri::generate_handler![ commands::chord_qualities, commands::spell_chord, commands::transpose_chord, commands::diatonic_chords, commands::count_voicings_blocking, commands::count_voicings, state::favorites, state::toggle_favorite, state::find_voicings, state::cancel_search, ])}setup est générique sur le Runtime : l’application passe le vrai (tauri::Builder::default(), WebView2 et compagnie), les tests passent tauri::test::mock_builder().
La page appelle la commande par son nom. Avec withGlobalTauri (leçon 16), invoke se trouve sur window.__TAURI__.core (extrait de ui/main.js, lignes 16-25) :
async function spell(symbol) { $("chord-error").textContent = ""; try { const chord = await invoke("spell_chord", { symbol }); showChord(chord); } catch (error) { $("chord-output").textContent = ""; $("chord-error").textContent = describe(error); }}Ce que la promesse renvoie, capturé dans la fenêtre en cours d’exécution grâce au protocole des outils de développement de WebView2 :
spell_chord {"symbol":"Bbm7"} resolved: {"symbol":"Bbm7","qualityName":"minor seventh","notes":["Bb","Db","F","Ab"],"intervals":["1","b3","5","b7"]}Arguments
Section intitulée « Arguments »Les arguments arrivent dans un seul objet JSON dont les clés sont les noms des paramètres en camelCase : tonic_note en Rust devient tonicNote en JavaScript (appeler Rust). Le type de chaque paramètre implémente Deserialize, donc un enum fonctionne aussi bien qu’une chaîne ou un nombre (extrait de src-tauri/src/commands.rs, lignes 67-73) :
/// `"major"` ou `"minor"` en JSON.#[derive(Debug, Clone, Copy, Deserialize)]#[serde(rename_all = "lowercase")]pub enum ScaleMode { Major, Minor,}#[tauri::command]pub fn diatonic_chords(tonic_note: &str, mode: ScaleMode) -> Result<Vec<ChordView>, CommandError> { let tonic: Note = tonic_note.parse()?; Ok(theory::diatonic_chords(tonic, mode.into()) .into_iter() .map(ChordView::from) .collect())}const chords = await invoke("diatonic_chords", { tonicNote: $("tonic-input").value, mode: $("mode-select").value,});Quand le JSON ne correspond pas, la commande ne s’exécute jamais : Tauri rejette l’appel avec un message qui nomme la commande et l’argument. Les vrais rejets, relevés dans la fenêtre :
diatonic_chords {"tonic_note":"C","mode":"major"} rejected (string): "invalid args `tonicNote` for command `diatonic_chords`: command diatonic_chords missing required key tonicNote"diatonic_chords {"tonicNote":"C","mode":"dorian"} rejected (string): "invalid args `mode` for command `diatonic_chords`: unknown variant `dorian`, expected `major` or `minor`"transpose_chord {"symbol":"C","semitones":"2"} rejected (string): "invalid args `semitones` for command `transpose_chord`: invalid type: string \"2\", expected i32"play_chord {"symbol":"C"} rejected (string): "Command play_chord not found"Le premier est l’erreur classique : des clés en snake_case côté JavaScript. Une commande synchrone peut prendre un &str, emprunté à la requête, sans copie. En JavaScript, tous les nombres sont des doubles, mais serde vérifie le type Rust : 1.5 pour un i32 échoue aussi (invalid type: floating point `1.5`, expected i32).
Valeurs de retour
Section intitulée « Valeurs de retour »Le type de retour doit implémenter Serialize. Les commandes du cours renvoient de petites structs faites pour la page plutôt que les types de theory, comme un contrôleur renvoie des DTO plutôt que des entités du domaine (extrait de src-tauri/src/commands.rs, lignes 8-27) :
/// Un accord tel que le reçoit le frontend : des chaînes prêtes à afficher.#[derive(Debug, Serialize)]#[serde(rename_all = "camelCase")]pub struct ChordView { symbol: String, quality_name: &'static str, notes: Vec<String>, intervals: Vec<&'static str>,}
impl From<Chord> for ChordView { fn from(chord: Chord) -> Self { ChordView { symbol: chord.to_string(), quality_name: chord.quality.name(), notes: chord.notes().iter().map(Note::to_string).collect(), intervals: chord.intervals(), } }}Tauri renomme les arguments en camelCase, mais serde sérialise les valeurs de retour telles quelles : sans #[serde(rename_all = "camelCase")], la page recevrait quality_name.
Oublier Serialize est une erreur de compilation, mais pas une erreur lisible (doctest compile_fail) :
struct Chord { symbol: String,}
#[tauri::command]fn spell_chord(symbol: String) -> Chord { Chord { symbol }}error[E0599]: the method `blocking_kind` exists for reference `&Chord`, but its trait bounds were not satisfied --> examples\e17_not_serialize.rs:5:1 | 1 | struct Chord { | ------------ doesn't satisfy `Chord: IpcResponse`... 5 | #[tauri::command] | ^^^^^^^^^^^^^^^^^ method cannot be called on `&Chord` due to unsatisfied trait bounds... = note: the following trait bounds were not satisfied: `Chord: IpcResponse` which is required by `&Chord: tauri::ipc::private::ResponseKind`Le message ne dit jamais Serialize. Le trait manquant, IpcResponse, est implémenté pour tout T: Serialize : quand une erreur mentionne IpcResponse ou blocking_kind, vérifiez le derive du type de retour.
Une commande qui renvoie Result<T, E> résout la promesse avec T ou la rejette avec E, et E doit lui aussi être sérialisable. String fonctionne, mais perd la nature de l’erreur. Les commandes du cours utilisent un seul type d’erreur avec un kind lisible par la machine, dans l’esprit de ProblemDetails (extrait de src-tauri/src/commands.rs, lignes 36-56) :
/// L'erreur qu'une commande en échec envoie au frontend, où la promesse se rejette avec/// `{ kind, message }`.#[derive(Debug, Serialize)]pub struct CommandError { kind: &'static str, message: String,}
impl From<TheoryError> for CommandError { fn from(error: TheoryError) -> Self { let kind = match error { TheoryError::Empty => "empty", TheoryError::InvalidNote(_) => "invalidNote", TheoryError::UnknownQuality { .. } => "unknownQuality", }; CommandError { kind, message: error.to_string(), } }}Ce sont les implémentations de From qui font fonctionner ? dans les commandes (leçon 6) : symbol.parse()? transforme une TheoryError en CommandError. Dans la fenêtre :
spell_chord {"symbol":"Cm9"} rejected (object): {"kind":"unknownQuality","message":"unknown chord quality `m9` in `Cm9`"}spell_chord {"symbol":""} rejected (object): {"kind":"empty","message":"empty chord symbol"}instanceof Error: false, kind: unknownQualityDeux conséquences côté JavaScript :
- La valeur de rejet est l’erreur sérialisée elle-même, pas une
ErrorJavaScript : pas de pile d’appels, eterror.messagen’existe que parce queCommandErrora un champmessage. - Quand Tauri refuse l’appel avant que votre fonction s’exécute (commande inconnue, arguments invalides), le rejet est une chaîne. La première version de l’explorateur affichait
undefined: undefineddans ces cas ; la page passe désormais par un seul utilitaire (extrait deui/main.js, lignes 10-14) :
// Un invoke rejeté donne notre CommandError, { kind, message }, ou une simple chaîne// quand Tauri refuse lui-même l'appel (commande inconnue, arguments invalides)function describe(error) { return typeof error === "string" ? error : `${error.kind}: ${error.message}`;}Une panique dans une commande n’est pas une valeur d’erreur : c’est un bug. Gardez unwrap pour les invariants, comme à la leçon 6, et renvoyez Err pour tout ce que l’utilisateur peut provoquer.
Où s’exécute une commande
Section intitulée « Où s’exécute une commande »C’est le point qu’un développeur WPF ne doit pas deviner. D’après le guide Tauri : les commandes sans async s’exécutent sur le thread principal, sauf si elles sont déclarées avec #[tauri::command(async)] ; les commandes async s’exécutent sur le runtime asynchrone de Tauri, un runtime tokio (leçon 13).
L’explorateur propose la même recherche des deux façons (extrait de src-tauri/src/commands.rs, lignes 129-149) :
/// Une commande synchrone : Tauri l'exécute sur le thread principal.#[tauri::command]pub fn count_voicings_blocking(symbol: &str) -> Result<usize, CommandError> { log_thread("count_voicings_blocking"); let chord: Chord = symbol.parse()?; Ok(count(&chord))}
/// Une commande async : Tauri l'exécute sur son runtime asynchrone, et la recherche,/// gourmande en CPU, part dans le pool de threads bloquants.#[tauri::command]pub async fn count_voicings(symbol: String) -> Result<usize, CommandError> { log_thread("count_voicings"); let chord: Chord = symbol.parse()?; let found = tauri::async_runtime::spawn_blocking(move || { log_thread("count_voicings (search)"); count(&chord) }) .await?; Ok(found)}Le terminal qui exécute tauri dev affiche :
count_voicings_blocking runs on thread "main"count_voicings runs on thread "tokio-rt-worker"count_voicings (search) runs on thread "tokio-rt-worker"La recherche essaie 7,5 millions de doigtés et prend environ une seconde dans un build debug. Mesuré dans la fenêtre, deux fois chacune, avec une deuxième commande envoyée 100 ms après la première et un timer JavaScript de 10 ms qui tourne pendant ce temps :
count_voicings_blocking: 27 voicings in 1058 ms; chord_qualities sent 100 ms later answered after 958 ms; longest gap between 10 ms JavaScript timer ticks: 12 mscount_voicings: 27 voicings in 1078 ms; chord_qualities sent 100 ms later answered after 2 ms; longest gap between 10 ms JavaScript timer ticks: 12 msEt une boucle PowerShell qui envoie WM_NULL à la fenêtre avec SendMessageTimeout pendant chaque appel :
blocking call: 1062 mswindow 'Chord Explorer': 64 WM_NULL probes, slowest answer 945 msasync call: 1095 mswindow 'Chord Explorer': 95 WM_NULL probes, slowest answer 4 msCe qui gèle n’est pas ce qu’attend un développeur WPF :
- La page continue de tourner : son timer JavaScript n’a jamais manqué un tick, parce que la webview est un autre processus.
- La fenêtre native ne répond plus à ses messages pendant presque une seconde : impossible de la déplacer, de la redimensionner ou de la fermer. Windows marque une fenêtre Ne répond pas au bout de cinq secondes de ce régime (
IsHungAppWindow). - Toutes les autres commandes attendent :
chord_qualitiesa mis 958 ms au lieu de 2, parce que le thread principal traite les requêtes IPC.
La règle est la même que pour le Dispatcher de WPF ou le thread d’application de JavaFX : rien de lent sur le thread principal. Avec Tauri, cela veut dire async pour tout ce qui attend ou calcule, et spawn_blocking pour le travail CPU, afin qu’il n’occupe pas non plus un des workers du runtime (exercice 3).
Où s’exécute chaque sorte de commande, et ce que le thread principal doit faire en plus.
flowchart LR page["page : invoke"] subgraph core["processus Rust"] main["thread principal : requêtes IPC, messages de la fenêtre native"] worker["worker du runtime tokio"] pool["pool de threads bloquants : travail lié au CPU"] end page -->|"commande sans async"| main page -->|"commande async"| worker worker -->|"spawn_blocking"| poolCe que la macro vérifie
Section intitulée « Ce que la macro vérifie »Comme #[tauri::command] génère du code, certaines erreurs sont détectées à la compilation, avec des messages qui viennent de Tauri plutôt que du langage. Chacune ci-dessous est un doctest compile_fail.
Une commande async avec un argument emprunté, comme &str, doit renvoyer un Result (tauri#2533) :
#[tauri::command]async fn count_voicings(symbol: &str) -> usize { symbol.len()}error[E0277]: async commands that contain references as inputs must return a `Result` --> examples\e17_async_borrow.rs:2:42 |2 | async fn count_voicings(symbol: &str) -> usize { | ^^^^^ the trait `AsyncCommandMustReturnResult` is not implemented for `usize` |help: the trait `AsyncCommandMustReturnResult` is implemented for `Result<A, B>`Le même code signale aussi une E0597 (__tauri_message__ does not live long enough) qui pointe sur l’attribut : corrigez la première erreur, la seconde disparaît. Prendre String au lieu de &str est l’autre correction, celle qu’utilise count_voicings.
Une commande pub à la racine de la crate entre en conflit avec les macros que génère #[tauri::command]. Les commandes de l’explorateur sont pub parce qu’elles vivent dans les modules commands et state, où c’est permis :
#[tauri::command]pub fn spell_chord(symbol: String) -> String { symbol}error[E0255]: the name `__cmd__spell_chord` is defined multiple times --> examples\e17_pub_root.rs:2:8 |1 | #[tauri::command] | ----------------- previous definition of the macro `__cmd__spell_chord` here2 | pub fn spell_chord(symbol: String) -> String { | -^^^^^^^^^^ | | | `__cmd__spell_chord` reimported here | help: remove unnecessary import | = note: `__cmd__spell_chord` must be defined only once in the macro namespace of this moduleCe que la macro ne vérifie pas : le nom de la commande en JavaScript. invoke("spell_chrod") compile, s’exécute et se rejette avec Command spell_chrod not found. La leçon 19 génère des types TypeScript à partir des signatures Rust pour combler ce manque.
Tester les commandes sans fenêtre
Section intitulée « Tester les commandes sans fenêtre »tauri::test fournit un runtime simulé : pas de fenêtre, pas de webview, mais la même couche IPC, donc un test voit exactement le JSON que recevrait la page (extrait de src-tauri/tests/ipc.rs, lignes 34-43) :
#[test]fn a_theory_error_rejects_with_kind_and_message() { let (_app, webview) = app(); let error = invoke(&webview, "spell_chord", json!({ "symbol": "Cm9" })); println!("spell_chord Cm9 -> {}", error.clone().unwrap_err()); assert_eq!( error, Err(json!({ "kind": "unknownQuality", "message": "unknown chord quality `m9` in `Cm9`" })) );}L’utilitaire invoke construit une InvokeRequest et la passe à get_ipc_response (tests/common/mod.rs). La documentation de Tauri marque ce module comme instable. La leçon 21 va plus loin, avec des tests WebDriver de la vraie fenêtre.
La requête porte aussi l’URL de la page, et c’est elle que les capabilities vérifient (leçon 20). Windows et Android servent les fichiers embarqués via http://tauri.localhost, les autres systèmes via tauri://localhost ; avec la mauvaise, chaque appel est refusé par spell_chord not allowed. Plugin not found. La première version de cette leçon codait en dur l’origine de Windows, et la CI l’a trouvé sur les runners macOS et Linux.
À retenir
Section intitulée « À retenir »- Une commande est une fonction Rust marquée
#[tauri::command], enregistrée dans un seulgenerate_handler!et appelée depuis la page avecinvoke(name, args), qui renvoie une promesse. - Les arguments sont des objets JSON à clés camelCase, désérialisés par serde ; les résultats sont sérialisés par serde tels quels, renommez donc vous-même leurs champs.
- Renvoyez
Result<T, E>avec unEsérialisable qui porte unkind; gérez en JavaScript à la fois vos objets d’erreur et les chaînes d’erreur de Tauri. - Les commandes synchrones s’exécutent sur le thread principal et bloquent la fenêtre native et toutes les autres commandes ; utilisez des commandes
async, etspawn_blockingpour le travail CPU. - La macro transforme certaines erreurs en erreurs de compilation aux messages inhabituels ; les noms de commandes ne sont vérifiés qu’à l’exécution.
Exercices
Section intitulée « Exercices »- Ajoutez une commande
scale_notesqui prend une tonique et un mode et renvoie les notes de la gamme sous forme de chaînes :Fmajeur donne["F", "G", "A", "Bb", "C", "D", "E"]. Que reçoit la page pour la toniqueH?
Solution
Extrait de src-tauri/tests/l17_solutions.rs, lignes 12-19 :
#[tauri::command]fn scale_notes(tonic_note: &str, mode: ScaleMode) -> Result<Vec<String>, CommandError> { let tonic: Note = tonic_note.parse()?; Ok(theory::scale_notes(tonic, mode.into()) .iter() .map(Note::to_string) .collect())}const notes = await invoke("scale_notes", { tonicNote: "F", mode: "major" });L’implémentation From<ScaleMode> for Mode de commands.rs fournit mode.into(). Pour H, la promesse se rejette avec la CommandError :
scale_notes H -> {"kind":"invalidNote","message":"`H` is not a note (expected A to G, then # or b)"}Dans l’application, la commande serait aussi ajoutée à generate_handler! ; le test de la solution l’enregistre sur sa propre application simulée.
- Prédisez avec quoi chaque appel se rejette, puis vérifiez avec les sorties ci-dessus :
invoke("transpose_chord", { symbol: "C" }),invoke("transpose_chord", { symbol: "C", semitones: 1.5 }),invoke("spell_chord", { symbol: 42 }).
Solution
Tauri refuse les trois avant que la fonction s’exécute, donc chaque rejet est une chaîne :
transpose_chord -> "invalid args `semitones` for command `transpose_chord`: command transpose_chord missing required key semitones"transpose_chord -> "invalid args `semitones` for command `transpose_chord`: invalid type: floating point `1.5`, expected i32"spell_chord -> "invalid args `symbol` for command `spell_chord`: invalid type: integer `42`, expected a borrowed string"semitones manque dans le premier appel. JavaScript n’a qu’un type de nombre, mais serde vérifie que 1.5 tient dans un i32. Pour 42, le message dit expected a borrowed string parce que le paramètre est un &str ; count_voicings, qui prend un String, dit expected a string.
- Que changerait-il si
count_voicingsfaisait la recherche directement dans la fonctionasync, sansspawn_blocking? Sur quel thread s’exécuterait-elle ?
Solution
Elle s’exécuterait sur un des threads workers du runtime asynchrone, comme le montre ce test (extrait de src-tauri/tests/l17_solutions.rs, lignes 43-49) :
#[tauri::command]async fn search_thread() -> String { std::thread::current() .name() .unwrap_or("unnamed") .to_string()}an async command runs on "tokio-rt-worker"La fenêtre resterait réactive, puisque ce n’est pas le thread principal. Mais une fonction async qui calcule pendant une seconde sans atteindre de .await monopolise son worker pendant toute cette seconde (leçon 13) : avec quelques recherches simultanées, toutes les autres commandes async, et chaque message de canal, attendent un worker libre. spawn_blocking déplace le calcul vers un pool séparé prévu pour le travail bloquant, et laisse les workers aux tâches qui attendent.