제가 볼려고 간단하게 정리한 글입니다.
자세한 내용은 아래 공식 메뉴얼을 확인해주세요!
참고자료
프론트엔드에서 Rust 호출
src-tauri/src/lib.rs 파일에서 명령어를 정의할 수 있습니다.
#[tauri::command]
fn my_custom_command() {
println!("I was invoked from JavaScript!");
}
- 이때
#[tauri::command]애노테이션을 사용해야 합니다.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![my_custom_command]) // <- 추가
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
src-tauri/src/lib.rs파일을 열어서 코드를 수정해야 합니다.invoke_handler메소드를 사용해줍니다.
.invoke_handler(tauri::generate_handler![cmd_a, cmd_b])
- 명령어 여러 개 넣을 수도 있습니다.
// When using the Tauri API npm package:
import { invoke } from '@tauri-apps/api/core';
// When using the Tauri global script (if not using the npm package)
// Be sure to set `app.withGlobalTauri` in `tauri.conf.json` to true
const invoke = window.__TAURI__.core.invoke;
// Invoke the command
invoke('my_custom_command');
- 마지막으로 자바 스크립트에서 호출해서 사용하면 됩니다.
분리된 모듈에서 정의
lib.rs 파일이 아닌 분리된 모듈에서 정의하려면 다른 방법을 써야합니다.
#[tauri::command]
pub fn my_custom_command() {
println!("I was invoked from JavaScript!");
}
src-tauri/src/commands.rs이런 파일에 분리시켜서 사용할 때pub키워드를 앞에 붙입니다.
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![commands::my_custom_command]) // <- 추가
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
- 그 다음에
invoke_handler메소드를 통해 핸들러를 달아줍니다. - 이때
crate::command이런 식으로 적어줘야 합니다.
인자 전달
#[tauri::command]
fn my_custom_command(invoke_message: String) {
println!("I was invoked from JavaScript, with this message: {}", invoke_message);
}
invoke('my_custom_command', { invokeMessage: 'Hello!' });
- 인자는 camelCase 형태로 적어야 합니다.
데이터 반환
#[tauri::command]
fn my_custom_command() -> String {
"Hello from Rust!".into()
}
invoke('my_custom_command').then((message) => console.log(message));
invoke함수는promise를 리턴합니다. 그래서 위처럼 풀어주는 과정이 필요합니다.
use tauri::ipc::Response;
#[tauri::command]
fn read_file() -> Response {
let data = std::fs::read("/path/to/file").unwrap();
tauri::ipc::Response::new(data)
}
- array buffers 데이터를 반환하는 방법입니다.
- 이 예제에서
tauri::ipc::Response를 사용하는데 최적화된 방법이라고 합니다.
오류 처리
#[tauri::command]
fn login(user: String, password: String) -> Result<String, String> {
if user == "tauri" && password == "tauri" {
// resolve
Ok("logged_in".to_string())
} else {
// reject
Err("invalid credentials".to_string())
}
}
Result형태로 반환되게 만듭니다.
invoke('login', { user: 'tauri', password: '0j4rijw8=' })
.then((message) => console.log(message))
.catch((error) => console.error(error));
- 자바스크립트에서 catch를 이용해서 오류를 감지하게끔 만듭니다.
오류 처리 (심화)
위에서는 그냥 String을 받아서 출력시켰습니다. 그런데 이렇게하면 에러 종류를 구분하기가 어려워집니다. 이를 위해서 error 인터페이스를 정의하는게 좋습니다.
#[derive(Debug, thiserror::Error)]
enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("failed to parse as string: {0}")]
Utf8(#[from] std::str::Utf8Error),
}
#[derive(serde::Serialize)]
#[serde(tag = "kind", content = "message")]
#[serde(rename_all = "camelCase")]
enum ErrorKind {
Io(String),
Utf8(String),
}
impl serde::Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
let error_message = self.to_string();
let error_kind = match self {
Self::Io(_) => ErrorKind::Io(error_message),
Self::Utf8(_) => ErrorKind::Utf8(error_message),
};
error_kind.serialize(serializer)
}
}
#[tauri::command]
fn read() -> Result<Vec<u8>, Error> {
let data = std::fs::read("/path/to/file")?;
Ok(data)
}
- 왜 이렇게 코드가 길어지냐... 하면
- 일단 Rust에서 프론트엔드로 전달될 때 JSON 형태로 직렬화가 됩니다.
- 이때
serde::Serialize규칙이 적용됩니다.
type ErrorKind = {
kind: 'io' | 'utf8';
message: string;
};
invoke('read').catch((e: ErrorKind) => {});
kind를 통해서 파일 접근 오류인지, 인코딩 에러인지 알 수 있습니다.
비동기 처리
UI의 멈춤이나 느려짐을 막기위해 무거운 작업을 비동기로 돌리는 경우가 많습니다.
// Declare the async function using String instead of &str, as &str is borrowed and thus unsupported
#[tauri::command]
async fn my_custom_command(value: String) -> String {
// Call another async function and wait for it to finish
some_async_function().await;
value
}
async키워드를 이용하면 됩니다.- 여기서 조심해야할 점은
&str이 아닌String을 쓰는 것입니다.- 정확히는 borrowed 타입은 비동기에서 지원하지 않기에 owned 타입으로 바꾸는 과정이 필요합니다.
- 여기에서는
&str->String처럼 빌린 타입을 소유 타입으로 바꿉니다.
// Return a Result<String, ()> to bypass the borrowing issue
#[tauri::command]
async fn my_custom_command(value: &str) -> Result<String, ()> {
// Call another async function and wait for it to finish
some_async_function().await;
// Note that the return value must be wrapped in `Ok()` now.
Ok(format!(value))
}
- 아니면
Result로 감싸는 방법이 있습니다. - 이 방법보단 그냥 owned 타입으로 바꾸는 방법을 많이 쓰는 것 같습니다.
invoke('my_custom_command', { value: 'Hello, Async!' }).then(() =>
console.log('Completed!')
);
- 자바스크립트에서는
promise형태로 반환되므로 다른 명령과 동일하게 작동됩니다.
채널
use tokio::io::AsyncReadExt;
#[tauri::command]
async fn load_image(path: std::path::PathBuf, reader: tauri::ipc::Channel<&[u8]>) {
// for simplicity this example does not include error handling
let mut file = tokio::fs::File::open(path).await.unwrap();
let mut chunk = vec![0; 4096];
loop {
let len = file.read(&mut chunk).await.unwrap();
if len == 0 {
// Length of zero means end of file.
break;
}
reader.send(&chunk).unwrap();
}
}
- 데이터를 스트리밍할 때 채널을 이용합니다.
- 이 예제에서는 4096 바이트 단위로 데이터를 전달합니다.
WebviewWindow 접근
#[tauri::command]
async fn my_custom_command(webview_window: tauri::WebviewWindow) {
println!("WebviewWindow: {}", webview_window.label());
}
AppHandle 접근
#[tauri::command]
async fn my_custom_command(app_handle: tauri::AppHandle) {
let app_dir = app_handle.path().app_dir();
use tauri::GlobalShortcutManager;
app_handle.global_shortcut_manager().register("CTRL + U", move || {});
}
이벤트 시스템
Tauri는 Rust와 프론트엔드 간의 양방향 통신에 사용할 수 있는 이벤트 시스템을 제공합니다. (이 파트의 설명은 간단하게만 적어놔서 공식 메뉴얼을 보는 걸 추천드립니다.)
| 커맨드 (invoke) | 이벤트 (emit, listen) | |
|---|---|---|
| 방향 | vue -> Rust | 양방향 |
| 타입 안정성 | O | X (JSON payload만) |
| 반환값 | promise | X |
| 비유 | 함수 호출 | broadcast |
Vue -> Rust
- global 시스템 (등록된 모든 리스너에게 전달)
-
import { emit } from '@tauri-apps/api/event'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; // emit(eventName, payload) emit('file-selected', '/path/to/file'); const appWebview = getCurrentWebviewWindow(); appWebview.emit('route-changed', { url: window.location.href });
-
- Webview 시스템 (특정 창에만 전달)
-
import { emitTo } from '@tauri-apps/api/event'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; // emitTo(webviewLabel, eventName, payload) emitTo('settings', 'settings-update-requested', { key: 'notification', value: 'all', }); const appWebview = getCurrentWebviewWindow(); appWebview.emitTo('editor', 'file-changed', { path: '/path/to/file', contents: 'file contents', });
-
Rust -> Vue
- Global Events
-
use tauri::{AppHandle, Emitter}; #[tauri::command] fn download(app: AppHandle, url: String) { app.emit("download-started", &url).unwrap(); for progress in [1, 15, 50, 80, 100] { app.emit("download-progress", progress).unwrap(); } app.emit("download-finished", &url).unwrap(); }
-
- Webview Events
-
use tauri::{AppHandle, Emitter}; #[tauri::command] fn login(app: AppHandle, user: String, password: String) { let authenticated = user == "tauri-apps" && password == "tauri"; let result = if authenticated { "loggedIn" } else { "invalidCredentials" }; app.emit_to("login", "login-result", result).unwrap(); }Emitter#emit_to를 이용해서 특정 창에 전달 가능합니다.
-
use tauri::{AppHandle, Emitter, EventTarget}; #[tauri::command] fn open_file(app: AppHandle, path: std::path::PathBuf) { app.emit_filter("open-file", path, |target| match target { EventTarget::WebviewWindow { label } => label == "main" || label == "file-viewer", _ => false, }).unwrap(); }- 또한
Emitter#emit_filter를 이용해서 창 목록에 전달할 수 있습니다.
- 또한
-
Listening to Events
- 프론트엔드에서
-
// Listening to global events import { listen } from '@tauri-apps/api/event'; type DownloadStarted = { url: string; downloadId: number; contentLength: number; }; listen<DownloadStarted>('download-started', (event) => { console.log( `downloading ${event.payload.contentLength} bytes from ${event.payload.url}` ); }); -
// Listening to webview-specific events import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; const appWebview = getCurrentWebviewWindow(); appWebview.listen<string>('logged-in', (event) => { localStorage.setItem('session-token', event.payload); }); -
// 이벤트 수신 중지 import { listen } from '@tauri-apps/api/event'; const unlisten = await listen('download-started', (event) => {}); unlisten();
-
- Rust에서
-
// Listening to global events use tauri::Listener; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .setup(|app| { app.listen("download-started", |event| { if let Ok(payload) = serde_json::from_str::<DownloadStarted>(&event.payload()) { println!("downloading {}", payload.url); } }); Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } -
// Listening to webview-specific events use tauri::{Listener, Manager}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .setup(|app| { let webview = app.get_webview_window("main").unwrap(); webview.listen("logged-in", |event| { let session_token = event.data; // save token.. }); Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } -
// 이벤트 수신 중지 // unlisten outside of the event handler scope: let event_id = app.listen("download-started", |event| {}); app.unlisten(event_id); // unlisten when some event criteria is matched let handle = app.handle().clone(); app.listen("status-changed", |event| { if event.data == "ready" { handle.unlisten(event.id); } });
-