17. Comandos de Tauri, llamar a Rust desde la interfaz
Secciones
Ajustes de lectura
Ninguna voz para este idioma en este navegador.
Más voces
Código y términos técnicos en inglés
Desactivado: la voz de la página lo lee todo, sin pausa entre dos voces.
Ninguna voz para este idioma en este navegador.
Más voces
Más voces: Microsoft Edge ofrece voces naturales en línea, y Windows (Configuración › Hora e idioma › Voz), macOS y Android instalan otras.
Ejemplo completo: l16-tauri/src-tauri/src/commands.rs y ui/main.js; los tests de src-tauri/tests/ llaman a cada comando a través de la capa IPC de Tauri (cargo test -p chord-explorer).
Lo que ya conoces
Sección titulada «Lo que ya conoces»Un comando es una función Rust que la página llama por su nombre, con argumentos JSON, y cuyo resultado vuelve como una promesa JavaScript. Se parece más a una API web que a un comando de WPF:
| Tauri | WPF | ASP.NET Core / Spring | Electron | |
|---|---|---|---|---|
| Se declara con | #[tauri::command] |
ICommand en el view model |
una acción de controlador, @RestController |
ipcMain.handle |
| Se registra con | generate_handler! |
un binding XAML | el enrutamiento | el nombre del canal |
| Se llama con | invoke |
un clic en un botón enlazado | fetch |
ipcRenderer.invoke detrás de contextBridge |
| Formato de datos | JSON a través de serde | objetos .NET, mismo proceso | JSON | clonado estructurado |
| Errores | una promesa rechazada con tu error serializado | excepciones | código de estado + ProblemDetails |
una promesa rechazada con un Error nuevo |
La página y el código Rust viven en procesos distintos (lección 16), así que no hay objeto compartido ni referencia: cada argumento se deserializa al llegar y cada resultado se serializa al salir.
Un primer comando
Sección titulada «Un primer comando»De src-tauri/src/commands.rs, líneas 95-99:
#[tauri::command]pub fn spell_chord(symbol: &str) -> Result<ChordView, CommandError> { let chord: Chord = symbol.parse()?; Ok(chord.into())}El atributo genera el pegamento: leer symbol del JSON de la petición, llamar a la función, serializar el Result. La función en sí sigue siendo una función Rust normal que puedes llamar y probar directamente.
Cada comando se registra una sola vez, en un único generate_handler! (llamar dos veces a invoke_handler conserva solo la última lista). La aplicación del curso lo hace en una función setup que reutilizan los tests (de src-tauri/src/lib.rs, líneas 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 es genérica sobre el Runtime: la aplicación pasa el real (tauri::Builder::default(), WebView2 y compañía), los tests pasan tauri::test::mock_builder().
La página llama al comando por su nombre. Con withGlobalTauri (lección 16), invoke está en window.__TAURI__.core (de ui/main.js, líneas 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); }}Con qué se resuelve la promesa, capturado en la ventana en ejecución mediante el protocolo de herramientas de desarrollo de WebView2:
spell_chord {"symbol":"Bbm7"} resolved: {"symbol":"Bbm7","qualityName":"minor seventh","notes":["Bb","Db","F","Ab"],"intervals":["1","b3","5","b7"]}Argumentos
Sección titulada «Argumentos»Los argumentos llegan como un único objeto JSON cuyas claves son los nombres de los parámetros en camelCase: tonic_note en Rust es tonicNote en JavaScript (llamar a Rust). El tipo de cada parámetro implementa Deserialize, así que un enum funciona igual que una cadena o un número (de src-tauri/src/commands.rs, líneas 67-73):
/// `"major"` o `"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,});Cuando el JSON no encaja, el comando nunca se ejecuta: Tauri rechaza la llamada con un mensaje que nombra el comando y el argumento. Los rechazos reales, desde la ventana:
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"El primero es el error clásico: claves en snake_case en JavaScript. Un comando síncrono puede recibir &str, prestado de la petición, sin copiar. En JavaScript todos los números son doubles, pero serde comprueba el tipo Rust: 1.5 para un i32 también falla (invalid type: floating point `1.5`, expected i32).
Valores de retorno
Sección titulada «Valores de retorno»El tipo de retorno debe implementar Serialize. Los comandos del curso devuelven pequeños structs hechos para la página en lugar de los tipos de theory, como un controlador devuelve DTO en lugar de entidades de dominio (de src-tauri/src/commands.rs, líneas 8-27):
/// Un acorde tal como lo recibe el frontend: cadenas listas para mostrar.#[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 renombra los argumentos a camelCase, pero serde serializa los valores de retorno tal cual: sin #[serde(rename_all = "camelCase")], la página recibiría quality_name.
Olvidar Serialize es un error de compilación, pero no uno legible (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`El mensaje nunca dice Serialize. El trait que falta, IpcResponse, está implementado para todo T: Serialize: cuando un error menciona IpcResponse o blocking_kind, revisa el derive del tipo de retorno.
Errores
Sección titulada «Errores»Un comando que devuelve Result<T, E> resuelve la promesa con T o la rechaza con E, y E también debe ser serializable. String funciona, pero pierde el tipo de error. Los comandos del curso usan un único tipo de error con un kind legible por máquina, en el espíritu de ProblemDetails (de src-tauri/src/commands.rs, líneas 36-56):
/// El error que un comando fallido envía al frontend, donde la promesa se rechaza con/// `{ 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(), } }}Las implementaciones de From son las que hacen funcionar ? en los comandos (lección 6): symbol.parse()? convierte un TheoryError en un CommandError. En la ventana:
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: unknownQualityDos consecuencias para el lado JavaScript:
- El valor del rechazo es el propio error serializado, no un
Errorde JavaScript: sin traza de pila, yerror.messageexiste solo porqueCommandErrortiene un campomessage. - Cuando Tauri rechaza la llamada antes de que se ejecute tu función (comando desconocido, argumentos inválidos), el rechazo es una cadena. La primera versión del explorador mostraba
undefined: undefineden esos casos; ahora la página pasa por una única función auxiliar (deui/main.js, líneas 10-14):
// Un invoke rechazado da nuestro CommandError, { kind, message }, o una simple cadena// cuando es Tauri quien rechaza la llamada (comando desconocido, argumentos inválidos)function describe(error) { return typeof error === "string" ? error : `${error.kind}: ${error.message}`;}Un panic dentro de un comando no es un valor de error: es un bug. Reserva unwrap para los invariantes, como en la lección 6, y devuelve Err para todo lo que el usuario pueda provocar.
Dónde se ejecuta un comando
Sección titulada «Dónde se ejecuta un comando»Esta es la parte que un desarrollador WPF no debe adivinar. Según la guía de Tauri: los comandos sin async se ejecutan en el hilo principal, salvo que se declaren con #[tauri::command(async)]; los comandos async se ejecutan en el runtime asíncrono de Tauri, un runtime tokio (lección 13).
El explorador tiene la misma búsqueda de las dos formas (de src-tauri/src/commands.rs, líneas 129-149):
/// Un comando síncrono: Tauri lo ejecuta en el hilo 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))}
/// Un comando async: Tauri lo ejecuta en su runtime asíncrono, y la búsqueda,/// intensiva en CPU, va al pool de hilos bloqueantes.#[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)}La terminal que ejecuta tauri dev imprime:
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 búsqueda prueba 7,5 millones de digitaciones y tarda más o menos un segundo en una compilación debug. Medido en la ventana, dos veces cada una, con un segundo comando enviado 100 ms después del primero y un temporizador JavaScript de 10 ms corriendo mientras tanto:
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 msY un bucle de PowerShell que envía WM_NULL a la ventana con SendMessageTimeout durante cada llamada:
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 msLo que se congela no es lo que espera un desarrollador WPF:
- La página sigue funcionando: su temporizador JavaScript no perdió ni un tic, porque el webview es otro proceso.
- La ventana nativa no responde a sus mensajes durante casi un segundo: no se puede mover, redimensionar ni cerrar. Windows marca una ventana como No responde tras cinco segundos así (
IsHungAppWindow). - Todos los demás comandos esperan:
chord_qualitiesnecesitó 958 ms en lugar de 2, porque el hilo principal atiende las peticiones IPC.
La regla es la misma que con el Dispatcher de WPF o el hilo de aplicación de JavaFX: nada lento en el hilo principal. En Tauri, eso significa async para todo lo que espera o calcula, y spawn_blocking para el trabajo intensivo de CPU, para que tampoco ocupe uno de los workers del runtime (ejercicio 3).
Dónde se ejecuta cada tipo de comando, y qué más tiene que hacer el hilo principal.
flowchart LR page["página: invoke"] subgraph core["proceso Rust"] main["hilo principal: peticiones IPC, mensajes de la ventana nativa"] worker["worker del runtime de tokio"] pool["pool de hilos bloqueantes: trabajo intensivo en CPU"] end page -->|"comando sin async"| main page -->|"comando async"| worker worker -->|"spawn_blocking"| poolLo que comprueba la macro
Sección titulada «Lo que comprueba la macro»Como #[tauri::command] genera código, algunos errores se detectan al compilar, con mensajes que vienen de Tauri y no del lenguaje. Cada uno de los siguientes es un doctest compile_fail.
Un comando async con un argumento prestado, como &str, debe devolver 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>`El mismo código también informa de un E0597 (__tauri_message__ no vive lo suficiente) que apunta al atributo: corrige el primer error y el segundo desaparece. Recibir String en lugar de &str es la otra solución, y la que usa count_voicings.
Un comando pub en la raíz del crate choca con las macros que genera #[tauri::command]. Los comandos del explorador son pub porque viven en los módulos commands y state, donde está permitido:
#[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 moduleLo que la macro no comprueba: el nombre del comando en JavaScript. invoke("spell_chrod") compila, se ejecuta y se rechaza con Command spell_chrod not found. La lección 19 estudia cómo generar tipos TypeScript a partir de las firmas Rust para cerrar ese hueco.
Probar comandos sin ventana
Sección titulada «Probar comandos sin ventana»tauri::test proporciona un runtime simulado: sin ventana ni webview, pero con la misma capa IPC, así que un test ve exactamente el JSON que recibiría la página (de src-tauri/tests/ipc.rs, líneas 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`" })) );}La función auxiliar invoke construye un InvokeRequest y lo pasa a get_ipc_response (tests/common/mod.rs). La documentación de Tauri marca el módulo como inestable. La lección 21 va más allá, con tests WebDriver de la ventana real.
La petición también lleva la URL de la página, y es contra ella que las capabilities comprueban la llamada (lección 20). Windows y Android sirven los archivos incrustados por http://tauri.localhost, y los demás sistemas por tauri://localhost; con la equivocada, cada llamada se rechaza con spell_chord not allowed. Plugin not found. La primera versión de esta lección fijaba el origen de Windows, y la CI lo encontró en los runners de macOS y Linux.
Puntos clave
Sección titulada «Puntos clave»- Un comando es una función Rust marcada con
#[tauri::command], registrada en un únicogenerate_handler!y llamada desde la página coninvoke(name, args), que devuelve una promesa. - Los argumentos son objetos JSON con claves en camelCase, deserializados con serde; los resultados se serializan con serde tal cual, así que renombra tú sus campos.
- Devuelve
Result<T, E>con unEserializable que lleve unkind; en JavaScript, trata tanto tus objetos de error como las cadenas de error de Tauri. - Los comandos síncronos se ejecutan en el hilo principal y bloquean la ventana nativa y todos los demás comandos; usa comandos
async, yspawn_blockingpara el trabajo intensivo de CPU. - La macro convierte algunos errores en errores de compilación con mensajes inusuales; los nombres de los comandos solo se comprueban en ejecución.
Ejercicios
Sección titulada «Ejercicios»- Añade un comando
scale_notesque reciba una tónica y un modo y devuelva las notas de la escala como cadenas:Fmayor da["F", "G", "A", "Bb", "C", "D", "E"]. ¿Qué recibe la página con la tónicaH?
Solución
De src-tauri/tests/l17_solutions.rs, líneas 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" });La implementación From<ScaleMode> for Mode de commands.rs proporciona mode.into(). Para H, la promesa se rechaza con el CommandError:
scale_notes H -> {"kind":"invalidNote","message":"`H` is not a note (expected A to G, then # or b)"}En la aplicación, el comando también se añadiría a generate_handler!; el test de la solución lo registra en su propia aplicación simulada.
- Predice con qué se rechaza cada llamada y luego compáralo con las salidas de arriba:
invoke("transpose_chord", { symbol: "C" }),invoke("transpose_chord", { symbol: "C", semitones: 1.5 }),invoke("spell_chord", { symbol: 42 }).
Solución
Tauri rechaza las tres antes de que se ejecute la función, así que cada rechazo es una cadena:
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"En la primera llamada falta semitones. JavaScript solo tiene un tipo numérico, pero serde comprueba que 1.5 quepa en un i32. Para 42, el mensaje dice expected a borrowed string porque el parámetro es &str; count_voicings, que recibe un String, dice expected a string.
- ¿Qué cambiaría si
count_voicingsejecutara la búsqueda directamente en la funciónasync, sinspawn_blocking? ¿En qué hilo se ejecutaría?
Solución
Se ejecutaría en uno de los hilos worker del runtime asíncrono, como muestra este test (de src-tauri/tests/l17_solutions.rs, líneas 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 ventana seguiría respondiendo, porque no es el hilo principal. Pero una función async que calcula durante un segundo sin llegar a un .await retiene su worker todo ese segundo (lección 13): con unas cuantas búsquedas a la vez, todos los demás comandos async, y todos los mensajes de canal, esperan un worker libre. spawn_blocking lleva el cálculo a un pool aparte pensado para el trabajo bloqueante, y deja los workers a las tareas que esperan.