Aller au contenu
learn

17. Commandes Tauri, appeler Rust depuis l'interface

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).

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.

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

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).

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: unknownQuality

Deux conséquences côté JavaScript :

  • La valeur de rejet est l’erreur sérialisée elle-même, pas une Error JavaScript : pas de pile d’appels, et error.message n’existe que parce que CommandError a un champ message.
  • 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: undefined dans ces cas ; la page passe désormais par un seul utilitaire (extrait de ui/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.

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 ms
count_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 ms

Et une boucle PowerShell qui envoie WM_NULL à la fenêtre avec SendMessageTimeout pendant chaque appel :

blocking call: 1062 ms
window 'Chord Explorer': 64 WM_NULL probes, slowest answer 945 ms
async call: 1095 ms
window 'Chord Explorer': 95 WM_NULL probes, slowest answer 4 ms

Ce 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_qualities a 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"| pool

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` here
2 | 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 module

Ce 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.

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.

  • Une commande est une fonction Rust marquée #[tauri::command], enregistrée dans un seul generate_handler! et appelée depuis la page avec invoke(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 un E sérialisable qui porte un kind ; 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, et spawn_blocking pour 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.
  1. Ajoutez une commande scale_notes qui prend une tonique et un mode et renvoie les notes de la gamme sous forme de chaînes : F majeur donne ["F", "G", "A", "Bb", "C", "D", "E"]. Que reçoit la page pour la tonique H ?
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.

  1. 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.

  1. Que changerait-il si count_voicings faisait la recherche directement dans la fonction async, sans spawn_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.